From 0f8e475388f98b9f90aa570f0e300dc5a1b759e4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:29:22 -0600 Subject: [PATCH] refactor(ble): Migrate to Nordic BLE Library for scanning and bonding (#3712) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/model/BTScanModel.kt | 272 ++++++------------ .../geeksville/mesh/model/DeviceListEntry.kt | 66 +++++ .../bluetooth/BluetoothLeScanner.kt | 48 ---- .../bluetooth/BluetoothRepository.kt | 147 ++++++---- .../bluetooth/BluetoothRepositoryModule.kt | 29 +- .../repository/bluetooth/BluetoothState.kt | 4 +- .../repository/radio/NordicBleInterface.kt | 13 +- .../mesh/ui/connections/ConnectionsScreen.kt | 17 +- 8 files changed, 275 insertions(+), 321 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index f443b275b..e09146993 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -17,9 +17,7 @@ package com.geeksville.mesh.model -import android.annotation.SuppressLint import android.app.Application -import android.bluetooth.BluetoothDevice import android.content.Context import android.hardware.usb.UsbManager import android.os.RemoteException @@ -29,18 +27,14 @@ import androidx.lifecycle.viewModelScope import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString -import com.geeksville.mesh.repository.radio.InterfaceId import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.service.MeshService -import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -54,51 +48,11 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.meshtastic -import org.meshtastic.core.strings.pairing_completed -import org.meshtastic.core.strings.pairing_failed_try_again import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import timber.log.Timber import javax.inject.Inject -/** - * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is - * more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for - * exhaustive `when` expressions in the code, making it more robust and readable. - * - * @param name The display name of the device. - * @param fullAddress The unique address of the device, prefixed with a type identifier. - * @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB). - */ -sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) { - val address: String - get() = fullAddress.substring(1) - - override fun toString(): String = - "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" - - @Suppress("MissingPermission") - data class Ble(val device: BluetoothDevice) : - DeviceListEntry( - name = device.name ?: "unnamed-${device.address}", - fullAddress = "x${device.address}", - bonded = device.bondState == BluetoothDevice.BOND_BONDED, - ) - - data class Usb( - private val radioInterfaceService: RadioInterfaceService, - private val usbManager: UsbManager, - val driver: UsbSerialDriver, - ) : DeviceListEntry( - name = driver.device.deviceName, - fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName), - bonded = usbManager.hasPermission(driver.device), - ) - - data class Tcp(override val name: String, override val fullAddress: String) : - DeviceListEntry(name, fullAddress, true) - - data class Mock(override val name: String) : DeviceListEntry(name, "m", true) -} +// ... (DeviceListEntry sealed class remains the same) ... @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") @@ -117,14 +71,18 @@ constructor( private val context: Context get() = application.applicationContext - val errorText = MutableLiveData(null) - val showMockInterface: StateFlow get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() - private val bleDevicesFlow: StateFlow> = + val errorText = MutableLiveData(null) + private val bondedBleDevicesFlow: StateFlow> = bluetoothRepository.state - .map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) }.sortedBy { it.name } } + .map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + private val scannedBleDevicesFlow: StateFlow> = + bluetoothRepository.scannedDevices + .map { peripherals -> peripherals.map { DeviceListEntry.Ble(it) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Flow for discovered TCP devices, using recent addresses for potential name enrichment @@ -151,7 +109,29 @@ constructor( } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + /** A combined list of bonded and scanned BLE devices for the UI. */ + val bleDevicesForUi: StateFlow> = + combine(bondedBleDevicesFlow, scannedBleDevicesFlow) { bonded, scanned -> + val bondedAddresses = bonded.map { it.fullAddress }.toSet() + val uniqueScanned = scanned.filterNot { it.fullAddress in bondedAddresses } + (bonded + uniqueScanned).sortedBy { it.name } + } + .stateInWhileSubscribed(initialValue = emptyList()) + + private val usbDevicesFlow: StateFlow> = + usbRepository.serialDevicesWithDrivers + .map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mockDevice = DeviceListEntry.Mock("Demo Mode") + // Flow for recent TCP devices, filtered to exclude any currently discovered devices + val usbDevicesForUi: StateFlow> = + combine(usbDevicesFlow, showMockInterface) { usb, showMock -> + usb + if (showMock) listOf(mockDevice) else emptyList() + } + .stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList()) + private val filteredRecentTcpDevicesFlow: StateFlow> = combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) { recentList, @@ -165,16 +145,6 @@ constructor( } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - private val usbDevicesFlow: StateFlow> = - usbRepository.serialDevicesWithDrivers - .map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } } - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - - val mockDevice = DeviceListEntry.Mock("Demo Mode") - - val bleDevicesForUi: StateFlow> = - bleDevicesFlow.stateInWhileSubscribed(initialValue = emptyList()) - /** UI StateFlow for discovered TCP devices. */ val discoveredTcpDevicesForUi: StateFlow> = processedDiscoveredTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf()) @@ -183,11 +153,14 @@ constructor( val recentTcpDevicesForUi: StateFlow> = filteredRecentTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf()) - val usbDevicesForUi: StateFlow> = - combine(usbDevicesFlow, showMockInterface) { usb, showMock -> - usb + if (showMock) listOf(mockDevice) else emptyList() - } - .stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList()) + val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow + + val selectedNotNullFlow: StateFlow = + selectedAddressFlow + .map { it ?: NO_DEVICE_SELECTED } + .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) + + val spinner: StateFlow = bluetoothRepository.isScanning init { serviceRepository.statusMessage.onEach { errorText.value = it }.launchIn(viewModelScope) @@ -196,6 +169,7 @@ constructor( override fun onCleared() { super.onCleared() + bluetoothRepository.stopScan() Timber.d("BTScanModel cleared") } @@ -203,66 +177,18 @@ constructor( errorText.value = text } - private var scanJob: Job? = null - - val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow - - val selectedNotNullFlow: StateFlow = - selectedAddressFlow - .map { it ?: NO_DEVICE_SELECTED } - .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) - - val scanResult = MutableLiveData>(mutableMapOf()) - - fun clearScanResults() { - stopScan() - scanResult.value = mutableMapOf() - } - fun stopScan() { - if (scanJob != null) { - Timber.d("stopping scan") - try { - scanJob?.cancel() - } catch (ex: Throwable) { - Timber.w("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") - } finally { - scanJob = null - } - } - _spinner.value = false + Timber.d("stopping scan") + bluetoothRepository.stopScan() } fun refreshPermissions() { - // Refresh the Bluetooth state to ensure we have the latest permissions bluetoothRepository.refreshState() } - @SuppressLint("MissingPermission") fun startScan() { - Timber.d("starting classic scan") - - _spinner.value = true - scanJob = - bluetoothRepository - .scan() - .onEach { result -> - val fullAddress = - radioInterfaceService.toInterfaceAddress(InterfaceId.BLUETOOTH, result.device.address) - // prevent log spam because we'll get lots of redundant scan results - val oldDevs = scanResult.value!! - val oldEntry = oldDevs[fullAddress] - // Don't spam the GUI with endless updates for non changing nodes - if ( - oldEntry == null || oldEntry.bonded != (result.device.bondState == BluetoothDevice.BOND_BONDED) - ) { - val entry = DeviceListEntry.Ble(result.device) - oldDevs[entry.fullAddress] = entry - scanResult.value = oldDevs - } - } - .catch { ex -> serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") } - .launchIn(viewModelScope) + Timber.d("starting ble scan") + bluetoothRepository.startScan() } private fun changeDeviceAddress(address: String) { @@ -270,34 +196,26 @@ constructor( serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) } } catch (ex: RemoteException) { Timber.e(ex, "changeDeviceSelection failed, probably it is shutting down") - // ignore the failure and the GUI won't be updating anyways } } - @SuppressLint("MissingPermission") - private fun requestBonding(it: DeviceListEntry) { - val device = bluetoothRepository.getRemoteDevice(it.address) ?: return - Timber.i("Starting bonding for ${device.anonymize}") - - bluetoothRepository - .createBond(device) - .onEach { state -> - Timber.d("Received bond state changed $state") - if (state != BluetoothDevice.BOND_BONDING) { - Timber.d("Bonding completed, state=$state") - if (state == BluetoothDevice.BOND_BONDED) { - setErrorText(getString(Res.string.pairing_completed)) - changeDeviceAddress("x${device.address}") - } else { - setErrorText(getString(Res.string.pairing_failed_try_again)) - } - } + /** Initiates the bonding process and connects to the device upon success. */ + private fun requestBonding(entry: DeviceListEntry.Ble) { + Timber.i("Starting bonding for ${entry.peripheral.address.anonymize}") + viewModelScope.launch { + @Suppress("TooGenericExceptionCaught") + try { + bluetoothRepository.bond(entry.peripheral) + Timber.i("Bonding complete for ${entry.peripheral.address.anonymize}, selecting device...") + changeDeviceAddress(entry.fullAddress) + } catch (ex: SecurityException) { + Timber.e(ex, "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted") + serviceRepository.setErrorMessage("Bonding failed: ${ex.message} Permissions not granted") + } catch (ex: Exception) { + Timber.e(ex, "Bonding failed for ${entry.peripheral.address.anonymize}") + serviceRepository.setErrorMessage("Bonding failed: ${ex.message}") } - .catch { ex -> - // We ignore missing BT adapters, because it lets us run on the emulator - Timber.w("Failed creating Bluetooth bond: ${ex.message}") - } - .launchIn(viewModelScope) + } } private fun requestPermission(it: DeviceListEntry.Usb) { @@ -323,54 +241,46 @@ constructor( viewModelScope.launch { recentAddressesDataSource.remove(address) } } - // Called by the GUI when a new device has been selected by the user - // @returns true if we were able to change to that item - fun onSelected(it: DeviceListEntry): Boolean { - // Using a `when` expression on the sealed class is much cleaner and safer than if/else chains. - // It ensures that all device types are handled, and the compiler can catch any omissions. - return when (it) { - is DeviceListEntry.Ble -> { - if (it.bonded) { - changeDeviceAddress(it.fullAddress) - true - } else { - requestBonding(it) - false - } - } - - is DeviceListEntry.Usb -> { - if (it.bonded) { - changeDeviceAddress(it.fullAddress) - true - } else { - requestPermission(it) - false - } - } - - is DeviceListEntry.Tcp -> { - viewModelScope.launch { - addRecentAddress(it.fullAddress, it.name) - changeDeviceAddress(it.fullAddress) - } - true - } - - is DeviceListEntry.Mock -> { + /** + * Called by the GUI when a new device has been selected by the user. + * + * @return true if the connection was initiated immediately. + */ + fun onSelected(it: DeviceListEntry): Boolean = when (it) { + is DeviceListEntry.Ble -> { + if (it.bonded) { changeDeviceAddress(it.fullAddress) true + } else { + requestBonding(it) + false } } + is DeviceListEntry.Usb -> { + if (it.bonded) { + changeDeviceAddress(it.fullAddress) + true + } else { + requestPermission(it) + false + } + } + is DeviceListEntry.Tcp -> { + viewModelScope.launch { + addRecentAddress(it.fullAddress, it.name) + changeDeviceAddress(it.fullAddress) + } + true + } + is DeviceListEntry.Mock -> { + changeDeviceAddress(it.fullAddress) + true + } } fun disconnect() { changeDeviceAddress(NO_DEVICE_SELECTED) } - - private val _spinner = MutableStateFlow(false) - val spinner: StateFlow - get() = _spinner.asStateFlow() } const val NO_DEVICE_SELECTED = "n" diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt new file mode 100644 index 000000000..406b29e4e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.model + +import android.hardware.usb.UsbManager +import com.geeksville.mesh.repository.radio.InterfaceId +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.hoho.android.usbserial.driver.UsbSerialDriver +import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.core.BondState +import org.meshtastic.core.model.util.anonymize + +/** + * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is + * more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for + * exhaustive `when` expressions in the code, making it more robust and readable. + * + * @param name The display name of the device. + * @param fullAddress The unique address of the device, prefixed with a type identifier. + * @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB). + */ +sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) { + val address: String + get() = fullAddress.substring(1) + + override fun toString(): String = + "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" + + @Suppress("MissingPermission") + data class Ble(val peripheral: Peripheral) : + DeviceListEntry( + name = peripheral.name ?: "unnamed-${peripheral.address}", + fullAddress = "x${peripheral.address}", + bonded = peripheral.bondState.value == BondState.BONDED, + ) + + data class Usb( + private val radioInterfaceService: RadioInterfaceService, + private val usbManager: UsbManager, + val driver: UsbSerialDriver, + ) : DeviceListEntry( + name = driver.device.deviceName, + fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName), + bonded = usbManager.hasPermission(driver.device), + ) + + data class Tcp(override val name: String, override val fullAddress: String) : + DeviceListEntry(name, fullAddress, true) + + data class Mock(override val name: String) : DeviceListEntry(name, "m", true) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt deleted file mode 100644 index 0f081a92c..000000000 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.geeksville.mesh.repository.bluetooth - -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import androidx.annotation.RequiresPermission -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -@RequiresPermission("android.permission.BLUETOOTH_SCAN") -internal fun BluetoothLeScanner.scan( - filters: List = emptyList(), - scanSettings: ScanSettings = ScanSettings.Builder().build(), -): Flow = callbackFlow { - val callback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - trySend(result) - } - - override fun onScanFailed(errorCode: Int) { - cancel("onScanFailed() called with errorCode: $errorCode") - } - } - startScan(filters, scanSettings, callback) - - awaitClose { stopScan(callback) } -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt index 5e1548abf..fe649c0a7 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -17,29 +17,36 @@ package com.geeksville.mesh.repository.bluetooth +import android.annotation.SuppressLint import android.app.Application import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import androidx.annotation.RequiresPermission import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.repository.radio.BleConstants.BLE_NAME_PATTERN +import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID import com.geeksville.mesh.util.registerReceiverCompat -import kotlinx.coroutines.flow.Flow +import dagger.Lazy +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.client.distinctByPeripheral +import no.nordicsemi.kotlin.ble.core.Manager import org.meshtastic.core.common.hasBluetoothPermission import org.meshtastic.core.di.CoroutineDispatchers import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.toKotlinUuid /** Repository responsible for maintaining and updating the state of Bluetooth availability. */ @Singleton @@ -47,10 +54,10 @@ class BluetoothRepository @Inject constructor( private val application: Application, - private val bluetoothAdapterLazy: dagger.Lazy, - private val bluetoothBroadcastReceiverLazy: dagger.Lazy, + private val bluetoothBroadcastReceiverLazy: Lazy, private val dispatchers: CoroutineDispatchers, private val processLifecycle: Lifecycle, + private val centralManager: CentralManager, ) { private val _state = MutableStateFlow( @@ -62,6 +69,14 @@ constructor( ) val state: StateFlow = _state.asStateFlow() + private val _scannedDevices = MutableStateFlow>(emptyList()) + val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning: StateFlow = _isScanning.asStateFlow() + + private var scanJob: Job? = null + init { processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() @@ -78,58 +93,86 @@ constructor( /** @return true for a valid Bluetooth address, false otherwise */ fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress) - fun getRemoteDevice(address: String): BluetoothDevice? = bluetoothAdapterLazy - .get() - ?.takeIf { application.hasBluetoothPermission() && isValid(address) } - ?.getRemoteDevice(address) + /** Starts a BLE scan for Meshtastic devices. The results are published to the [scannedDevices] flow. */ + @OptIn(ExperimentalUuidApi::class) + @SuppressLint("MissingPermission") + fun startScan() { + if (isScanning.value) return - private fun getBluetoothLeScanner(): BluetoothLeScanner? = - bluetoothAdapterLazy.get()?.takeIf { application.hasBluetoothPermission() }?.bluetoothLeScanner + scanJob?.cancel() + _scannedDevices.value = emptyList() - fun scan(): Flow { - val filter = - ScanFilter.Builder() - // Samsung doesn't seem to filter properly by service so this can't work - // see - // https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960 - // and https://stackoverflow.com/a/45590493 - // .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID)) - .build() - - val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() - - return getBluetoothLeScanner()?.scan(listOf(filter), settings)?.filter { - it.device.name?.matches(Regex(BLE_NAME_PATTERN)) == true - } ?: emptyFlow() + scanJob = + processLifecycle.coroutineScope.launch(dispatchers.default) { + centralManager + .scan(5.seconds) { ServiceUuid(BTM_SERVICE_UUID.toKotlinUuid()) } + .distinctByPeripheral() + .map { it.peripheral } + .onStart { _isScanning.value = true } + .onCompletion { _isScanning.value = false } + .catch { ex -> + Timber.w(ex, "Bluetooth scan failed") + _isScanning.value = false + } + .collect { peripheral -> + // Add or update the peripheral in our list + val currentList = _scannedDevices.value + _scannedDevices.value = + (currentList.filterNot { it.address == peripheral.address } + peripheral) + } + } } - @RequiresPermission("android.permission.BLUETOOTH_CONNECT") - fun createBond(device: BluetoothDevice): Flow = device.createBond(application) + /** Stops the currently active BLE scan. */ + fun stopScan() { + scanJob?.cancel() + scanJob = null + _isScanning.value = false + } + /** + * Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding + * process is finished. After successful bonding, the repository's state is refreshed to include the new bonded + * device. + * + * @param peripheral The peripheral to bond with. + * @throws SecurityException if required Bluetooth permissions are not granted. + * @throws Exception if the bonding process fails. + */ + @SuppressLint("MissingPermission") + suspend fun bond(peripheral: Peripheral) { + peripheral.createBond() + refreshState() + } + + @OptIn(ExperimentalUuidApi::class) internal suspend fun updateBluetoothState() { val hasPerms = application.hasBluetoothPermission() - val newState: BluetoothState = - bluetoothAdapterLazy.get()?.let { adapter -> - val enabled = adapter.isEnabled - val bondedDevices = adapter.takeIf { hasPerms }?.bondedDevices ?: emptySet() - - BluetoothState( - hasPermissions = hasPerms, - enabled = enabled, - bondedDevices = - if (!enabled) { - emptyList() - } else { - bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true } - }, - ) - } ?: BluetoothState() + val enabled = centralManager.state.value == Manager.State.POWERED_ON + val newState = + BluetoothState( + hasPermissions = hasPerms, + enabled = enabled, + bondedDevices = getBondedAppPeripherals(enabled), + ) _state.emit(newState) Timber.d("Detected our bluetooth access=$newState") } - companion object { - const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$" + private fun getBondedAppPeripherals(enabled: Boolean): List = if (enabled) { + centralManager.getBondedPeripherals().filter(::isMatchingPeripheral) + } else { + emptyList() + } + + /** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */ + @OptIn(ExperimentalUuidApi::class) + private fun isMatchingPeripheral(peripheral: Peripheral): Boolean { + val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false + val hasRequiredService = + peripheral.services(listOf(BTM_SERVICE_UUID.toKotlinUuid())).value?.isNotEmpty() ?: false + + return nameMatches || hasRequiredService } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt index 203e8ca3e..2c1e5e569 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt @@ -17,9 +17,6 @@ package com.geeksville.mesh.repository.bluetooth -import android.app.Application -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager import android.content.Context import dagger.Module import dagger.Provides @@ -35,23 +32,13 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -interface BluetoothRepositoryModule { - companion object { - @Provides - fun provideBluetoothManager(application: Application): BluetoothManager? = - application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? +object BluetoothRepositoryModule { + @Provides + @Singleton + fun provideCentralManager(@ApplicationContext context: Context, coroutineScope: CoroutineScope): CentralManager = + CentralManager.native(context, coroutineScope) - @Provides fun provideBluetoothAdapter(service: BluetoothManager?): BluetoothAdapter? = service?.adapter - - @Provides - @Singleton - fun provideCentralManager( - @ApplicationContext context: Context, - coroutineScope: CoroutineScope, - ): CentralManager = CentralManager.native(context, coroutineScope) - - @Provides - @Singleton - fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - } + @Provides + @Singleton + fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt index c2b2465d2..50edd9366 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt @@ -17,7 +17,7 @@ package com.geeksville.mesh.repository.bluetooth -import android.bluetooth.BluetoothDevice +import no.nordicsemi.kotlin.ble.client.android.Peripheral import org.meshtastic.core.model.util.anonymize /** A snapshot in time of the state of the bluetooth subsystem. */ @@ -27,7 +27,7 @@ data class BluetoothState( /** If we have adequate permissions and bluetooth is enabled */ val enabled: Boolean = false, /** If enabled, a list of the currently bonded devices */ - val bondedDevices: List = emptyList(), + val bondedDevices: List = emptyList(), ) { override fun toString(): String = "BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index 2c933f8c9..08816bb6e 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -18,11 +18,11 @@ package com.geeksville.mesh.repository.radio import android.annotation.SuppressLint -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_FROMNUM_CHARACTER -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_FROMRADIO_CHARACTER -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_LOGRADIO_CHARACTER -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_SERVICE_UUID -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_TORADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMNUM_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMRADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_LOGRADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID +import com.geeksville.mesh.repository.radio.BleConstants.BTM_TORADIO_CHARACTER import com.geeksville.mesh.service.RadioNotConnectedException import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -322,7 +322,8 @@ constructor( } } -object BleUuidConstants { +object BleConstants { + const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$" val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd") val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7") val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453") diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index d7b858cf9..cac2623df 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.model.BTScanModel -import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.ui.connections.components.BLEDevices import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo @@ -117,8 +116,7 @@ fun ConnectionsScreen( val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle() val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET - val bondedBleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() - val scannedBleDevices by scanModel.scanResult.observeAsState(emptyMap()) + val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle() val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle() @@ -235,13 +233,11 @@ fun ConnectionsScreen( Column(modifier = Modifier.fillMaxSize()) { when (selectedDeviceType) { DeviceType.BLE -> { + val (bonded, available) = bleDevices.partition { it.bonded } BLEDevices( connectionState = connectionState, - bondedDevices = bondedBleDevices, - availableDevices = - scannedBleDevices.values.toList().filterNot { available -> - bondedBleDevices.any { it.address == available.address } - }, + bondedDevices = bonded, + availableDevices = available, selectedDevice = selectedDevice, scanModel = scanModel, bluetoothEnabled = bluetoothState.enabled, @@ -273,10 +269,9 @@ fun ConnectionsScreen( // Warning Not Paired val hasShownNotPairedWarning by connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle() + val (bonded, _) = bleDevices.partition { it.bonded } val showWarningNotPaired = - !connectionState.isConnected() && - !hasShownNotPairedWarning && - bondedBleDevices.none { it is DeviceListEntry.Ble && it.bonded } + !connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty() if (showWarningNotPaired) { Text( text = stringResource(Res.string.warning_not_paired),