mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
a22513660a
commit
0f8e475388
8 changed files with 275 additions and 321 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue