mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(ble): Centralize BLE logic into a core module (#4550)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
7a68802bc2
commit
6bfa5b5f70
214 changed files with 3471 additions and 2405 deletions
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.android.common.core.simpleSharedFlow
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private const val SERVICE_DISCOVERY_TIMEOUT_MS = 10_000L
|
||||
|
||||
/**
|
||||
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
|
||||
* discovery.
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for connection.
|
||||
* @param scope The [CoroutineScope] in which to monitor connection state.
|
||||
* @param tag A tag for logging.
|
||||
*/
|
||||
class BleConnection(
|
||||
private val centralManager: CentralManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val tag: String = "BLE",
|
||||
) {
|
||||
/** The currently connected [Peripheral], or null if not connected. */
|
||||
var peripheral: Peripheral? = null
|
||||
private set
|
||||
|
||||
private val _connectionState = simpleSharedFlow<ConnectionState>()
|
||||
|
||||
/** A flow of [ConnectionState] changes for the current [peripheral]. */
|
||||
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
private var stateJob: Job? = null
|
||||
|
||||
/**
|
||||
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
|
||||
* Use [connectAndAwait] if you need to wait for the connection to be established.
|
||||
*
|
||||
* @param p The peripheral to connect to.
|
||||
*/
|
||||
suspend fun connect(p: Peripheral) {
|
||||
stateJob?.cancel()
|
||||
peripheral = p
|
||||
|
||||
centralManager.connect(
|
||||
peripheral = p,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
|
||||
stateJob =
|
||||
p.state
|
||||
.onEach { state ->
|
||||
Logger.d { "[$tag] Connection state changed to $state" }
|
||||
|
||||
if (state is ConnectionState.Connected) {
|
||||
p.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
observePeripheralDetails(p)
|
||||
}
|
||||
|
||||
_connectionState.emit(state)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the given [Peripheral] and waits for a terminal state (Connected or Disconnected).
|
||||
*
|
||||
* @param p The peripheral to connect to.
|
||||
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
|
||||
* @return The final [ConnectionState].
|
||||
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
|
||||
*/
|
||||
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
|
||||
connect(p)
|
||||
return withTimeout(timeoutMs) {
|
||||
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
|
||||
}
|
||||
}
|
||||
|
||||
/** Discovers characteristics for a specific service with retries. */
|
||||
@Suppress("ReturnCount")
|
||||
suspend fun discoverCharacteristics(
|
||||
serviceUuid: Uuid,
|
||||
characteristicUuids: List<Uuid>,
|
||||
): Map<Uuid, RemoteCharacteristic>? = retryBleOperation(tag = tag) {
|
||||
val p = peripheral ?: return@retryBleOperation null
|
||||
val services =
|
||||
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
|
||||
val service = services.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
|
||||
|
||||
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
|
||||
for (uuid in characteristicUuids) {
|
||||
val char = service.characteristics.find { it.uuid == uuid }
|
||||
if (char != null) {
|
||||
result[uuid] = char
|
||||
}
|
||||
}
|
||||
return@retryBleOperation if (result.size == characteristicUuids.size) result else null
|
||||
}
|
||||
|
||||
private fun observePeripheralDetails(p: Peripheral) {
|
||||
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
|
||||
|
||||
p.connectionParameters
|
||||
.onEach { params -> Logger.i { "[$tag] BLE connection parameters changed to $params" } }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/** Disconnects from the current peripheral. */
|
||||
suspend fun disconnect() {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
peripheral?.disconnect()
|
||||
peripheral = null
|
||||
}
|
||||
}
|
||||
135
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
Normal file
135
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.ScanningException
|
||||
import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
|
||||
import no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException
|
||||
import no.nordicsemi.kotlin.ble.core.exception.GattException
|
||||
import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
|
||||
|
||||
/**
|
||||
* Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more
|
||||
* granular error handling and intelligent reconnection strategies.
|
||||
*/
|
||||
sealed class BleError(val message: String, val shouldReconnect: Boolean) {
|
||||
|
||||
/**
|
||||
* An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a
|
||||
* reconnect.
|
||||
*/
|
||||
data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false)
|
||||
|
||||
/**
|
||||
* An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is
|
||||
* warranted.
|
||||
*/
|
||||
class ConnectionFailed(exception: Throwable) :
|
||||
BleError("Connection failed: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/**
|
||||
* An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect
|
||||
* attempt is warranted.
|
||||
*/
|
||||
class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true)
|
||||
|
||||
/**
|
||||
* An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt
|
||||
* is warranted.
|
||||
*/
|
||||
class Disconnected(reason: ConnectionState.Disconnected.Reason?) :
|
||||
BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true)
|
||||
|
||||
/**
|
||||
* Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error.
|
||||
*
|
||||
* @param exception The underlying GattException.
|
||||
*/
|
||||
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/**
|
||||
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
|
||||
*
|
||||
* @param exception The underlying BluetoothException.
|
||||
*/
|
||||
class BluetoothError(exception: BluetoothException) :
|
||||
BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** The BLE manager was closed. This is a non-recoverable error. */
|
||||
class ManagerClosed(exception: ManagerClosedException) :
|
||||
BleError("Manager closed: ${exception.message}", shouldReconnect = false)
|
||||
|
||||
/** A BLE operation failed. This may be recoverable. */
|
||||
class OperationFailed(exception: OperationFailedException) :
|
||||
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/**
|
||||
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
|
||||
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
|
||||
*/
|
||||
class InvalidAttribute(exception: InvalidAttributeException) :
|
||||
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** An error occurred while scanning for devices. This may be recoverable. */
|
||||
class Scanning(exception: ScanningException) :
|
||||
BleError("Scanning error: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** Bluetooth is unavailable on the device. This is a non-recoverable error. */
|
||||
class BluetoothUnavailable(exception: BluetoothUnavailableException) :
|
||||
BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false)
|
||||
|
||||
/** The peripheral is not connected. This may be recoverable. */
|
||||
class PeripheralNotConnected(exception: PeripheralNotConnectedException) :
|
||||
BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** A value did not match what was expected. This may be recoverable. */
|
||||
class ValueDoesNotMatch(exception: ValueDoesNotMatchException) :
|
||||
BleError("Value does not match: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
/** A generic error for other exceptions that may occur. */
|
||||
class GenericError(exception: Throwable) :
|
||||
BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true)
|
||||
|
||||
companion object {
|
||||
fun from(exception: Throwable): BleError = when (exception) {
|
||||
is GattException -> {
|
||||
when (exception) {
|
||||
is ConnectionFailedException -> ConnectionFailed(exception)
|
||||
is PeripheralNotConnectedException -> PeripheralNotConnected(exception)
|
||||
is OperationFailedException -> OperationFailed(exception)
|
||||
is ValueDoesNotMatchException -> ValueDoesNotMatch(exception)
|
||||
else -> GattError(exception)
|
||||
}
|
||||
}
|
||||
is BluetoothException -> {
|
||||
when (exception) {
|
||||
is BluetoothUnavailableException -> BluetoothUnavailable(exception)
|
||||
is InvalidAttributeException -> InvalidAttribute(exception)
|
||||
is ScanningException -> Scanning(exception)
|
||||
else -> BluetoothError(exception)
|
||||
}
|
||||
}
|
||||
else -> GenericError(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.native
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object BleModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
|
||||
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
|
||||
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBleSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
58
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
Normal file
58
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Retries a BLE operation a specified number of times with a delay between attempts.
|
||||
*
|
||||
* @param count The number of attempts to make.
|
||||
* @param delayMs The delay in milliseconds between attempts.
|
||||
* @param tag A tag for logging.
|
||||
* @param block The operation to perform.
|
||||
* @return The result of the operation.
|
||||
* @throws Exception if the operation fails after all attempts.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
suspend fun <T> retryBleOperation(
|
||||
count: Int = 3,
|
||||
delayMs: Long = 500L,
|
||||
tag: String = "BLE",
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var currentAttempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
currentAttempt++
|
||||
if (currentAttempt >= count) {
|
||||
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
|
||||
throw e
|
||||
}
|
||||
Logger.w(e) {
|
||||
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
|
||||
}
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConjunctionFilterScope
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* A wrapper around [CentralManager]'s scanning capabilities to provide a consistent and easy-to-use API for BLE
|
||||
* scanning across the application.
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for scanning.
|
||||
*/
|
||||
class BleScanner @Inject constructor(private val centralManager: CentralManager) {
|
||||
|
||||
/**
|
||||
* Scans for BLE devices.
|
||||
*
|
||||
* @param timeout The duration of the scan.
|
||||
* @param filterBlock Optional filter configuration block.
|
||||
* @return A [Flow] of discovered [Peripheral]s.
|
||||
*/
|
||||
fun scan(timeout: Duration, filterBlock: (ConjunctionFilterScope.() -> Unit)? = null): Flow<Peripheral> =
|
||||
if (filterBlock != null) {
|
||||
centralManager.scan(timeout, filterBlock)
|
||||
} else {
|
||||
centralManager.scan(timeout)
|
||||
}
|
||||
.distinctByPeripheral()
|
||||
.map { it.peripheral }
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import dagger.Lazy
|
||||
import javax.inject.Inject
|
||||
|
||||
/** BroadcastReceiver to handle Bluetooth adapter and device state changes. */
|
||||
class BluetoothBroadcastReceiver @Inject constructor(private val bluetoothRepository: Lazy<BluetoothRepository>) :
|
||||
BroadcastReceiver() {
|
||||
|
||||
val intentFilter: IntentFilter
|
||||
get() =
|
||||
IntentFilter().apply {
|
||||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
BluetoothAdapter.ACTION_STATE_CHANGED,
|
||||
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
|
||||
-> {
|
||||
bluetoothRepository.get().refreshState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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.core.android.AndroidEnvironment
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.di.ProcessLifecycle
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
|
||||
@Singleton
|
||||
class BluetoothRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@ProcessLifecycle private val processLifecycle: Lifecycle,
|
||||
private val centralManager: CentralManager,
|
||||
private val androidEnvironment: AndroidEnvironment,
|
||||
) {
|
||||
private val _state =
|
||||
MutableStateFlow(
|
||||
BluetoothState(
|
||||
// Assume we have permission until we get our initial state update to prevent premature
|
||||
// notifications to the user.
|
||||
hasPermissions = true,
|
||||
),
|
||||
)
|
||||
val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshState() {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
|
||||
}
|
||||
|
||||
/** @return true for a valid Bluetooth address, false otherwise */
|
||||
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
internal suspend fun updateBluetoothState() {
|
||||
val hasPerms =
|
||||
if (androidEnvironment.androidSdkVersion >= Build.VERSION_CODES.S) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val newState =
|
||||
BluetoothState(
|
||||
hasPermissions = hasPerms,
|
||||
enabled = enabled,
|
||||
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
|
||||
)
|
||||
|
||||
_state.emit(newState)
|
||||
Logger.d { "Detected our bluetooth access=$newState" }
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<Peripheral> =
|
||||
if (enabled && hasPerms) {
|
||||
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. */
|
||||
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
|
||||
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
|
||||
val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
|
||||
|
||||
return nameMatches || hasRequiredService
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
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. */
|
||||
data class BluetoothState(
|
||||
/** Whether we have adequate permissions to query bluetooth state */
|
||||
val hasPermissions: Boolean = false,
|
||||
/** 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<Peripheral> = emptyList(),
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {
|
||||
it.anonymize
|
||||
}})"
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/** Constants for Meshtastic Bluetooth LE interaction. */
|
||||
object MeshtasticBleConstants {
|
||||
/** Pattern for Meshtastic device names (e.g., Meshtastic_1234). */
|
||||
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
|
||||
|
||||
/** The Meshtastic service UUID. */
|
||||
val SERVICE_UUID: Uuid = Uuid.parse("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
|
||||
|
||||
/** Characteristic for sending data to the radio. */
|
||||
val TORADIO_CHARACTERISTIC: Uuid = Uuid.parse("f75c76d2-129e-4dad-a1dd-7866124401e7")
|
||||
|
||||
/** Characteristic for receiving packet count notifications. */
|
||||
val FROMNUM_CHARACTERISTIC: Uuid = Uuid.parse("ed9da18c-a800-4f66-a670-aa7547e34453")
|
||||
|
||||
/** Characteristic for reading data from the radio. */
|
||||
val FROMRADIO_CHARACTERISTIC: Uuid = Uuid.parse("2c55e69e-4993-11ed-b878-0242ac120002")
|
||||
|
||||
/** Characteristic for receiving log notifications from the radio. */
|
||||
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.core.ble
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.testing.TestLifecycleOwner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.mock.mock
|
||||
import no.nordicsemi.kotlin.ble.client.mock.AddressType
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
|
||||
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
|
||||
import no.nordicsemi.kotlin.ble.client.mock.Proximity
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BluetoothRepositoryTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, default = testDispatcher, io = testDispatcher)
|
||||
|
||||
private lateinit var mockEnvironment: MockAndroidEnvironment
|
||||
private lateinit var lifecycleOwner: TestLifecycleOwner
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockEnvironment =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = true,
|
||||
isBluetoothConnectPermissionGranted = true,
|
||||
)
|
||||
lifecycleOwner =
|
||||
TestLifecycleOwner(initialState = Lifecycle.State.RESUMED, coroutineDispatcher = testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state reflects environment`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
|
||||
runCurrent()
|
||||
val state = repository.state.value
|
||||
assertTrue(state.enabled)
|
||||
assertTrue(state.hasPermissions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state updates when bluetooth is disabled`() = runTest(testDispatcher) {
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
|
||||
mockEnvironment.simulatePowerOff()
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertFalse(state.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bonded devices are correctly identified`() = runTest(testDispatcher) {
|
||||
val address = "C0:00:00:00:00:03"
|
||||
val peripheral =
|
||||
PeripheralSpec.simulatePeripheral(
|
||||
identifier = address,
|
||||
addressType = AddressType.RANDOM_STATIC,
|
||||
proximity = Proximity.IMMEDIATE,
|
||||
) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Meshtastic_5678")
|
||||
}
|
||||
connectable(
|
||||
name = "Meshtastic_5678",
|
||||
isBonded = true,
|
||||
eventHandler = object : PeripheralSpecEventHandler {},
|
||||
) {
|
||||
Service(uuid = SERVICE_UUID) {}
|
||||
}
|
||||
}
|
||||
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
centralManager.simulatePeripherals(listOf(peripheral))
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
|
||||
repository.refreshState()
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
|
||||
assertEquals(address, state.bondedDevices.first().address)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue