refactor(ble): Migrate to Nordic BLE Library for scanning and bonding (#3712)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-11-15 22:29:22 -06:00 committed by GitHub
parent a22513660a
commit 0f8e475388
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 275 additions and 321 deletions

View file

@ -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<String?>(null)
val showMockInterface: StateFlow<Boolean>
get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
private val bleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
val errorText = MutableLiveData<String?>(null)
private val bondedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
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<List<DeviceListEntry.Ble>> =
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<List<DeviceListEntry>> =
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<List<DeviceListEntry.Usb>> =
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<List<DeviceListEntry>> =
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<List<DeviceListEntry.Tcp>> =
combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) {
recentList,
@ -165,16 +145,6 @@ constructor(
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val usbDevicesFlow: StateFlow<List<DeviceListEntry.Usb>> =
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<List<DeviceListEntry>> =
bleDevicesFlow.stateInWhileSubscribed(initialValue = emptyList())
/** UI StateFlow for discovered TCP devices. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
processedDiscoveredTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
@ -183,11 +153,14 @@ constructor(
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
filteredRecentTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(usbDevicesFlow, showMockInterface) { usb, showMock ->
usb + if (showMock) listOf(mockDevice) else emptyList()
}
.stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList())
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
val selectedNotNullFlow: StateFlow<String> =
selectedAddressFlow
.map { it ?: NO_DEVICE_SELECTED }
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
val spinner: StateFlow<Boolean> = 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<String?> = radioInterfaceService.currentDeviceAddressFlow
val selectedNotNullFlow: StateFlow<String> =
selectedAddressFlow
.map { it ?: NO_DEVICE_SELECTED }
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
val scanResult = MutableLiveData<MutableMap<String, DeviceListEntry>>(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<Boolean>
get() = _spinner.asStateFlow()
}
const val NO_DEVICE_SELECTED = "n"

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ScanFilter> = emptyList(),
scanSettings: ScanSettings = ScanSettings.Builder().build(),
): Flow<ScanResult> = 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) }
}

View file

@ -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<BluetoothAdapter?>,
private val bluetoothBroadcastReceiverLazy: dagger.Lazy<BluetoothBroadcastReceiver>,
private val bluetoothBroadcastReceiverLazy: Lazy<BluetoothBroadcastReceiver>,
private val dispatchers: CoroutineDispatchers,
private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
) {
private val _state =
MutableStateFlow(
@ -62,6 +69,14 @@ constructor(
)
val state: StateFlow<BluetoothState> = _state.asStateFlow()
private val _scannedDevices = MutableStateFlow<List<Peripheral>>(emptyList())
val scannedDevices: StateFlow<List<Peripheral>> = _scannedDevices.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _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<ScanResult> {
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<Int> = 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<Peripheral> = 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
}
}

View file

@ -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)
}

View file

@ -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<BluetoothDevice> = emptyList(),
val bondedDevices: List<Peripheral> = emptyList(),
) {
override fun toString(): String =
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {

View file

@ -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")

View file

@ -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),