mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(ble): improve connection lifecycle and enhance OTA reliability (#4721)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
5a5aa1f026
commit
68b2b6d88e
19 changed files with 741 additions and 537 deletions
|
|
@ -17,33 +17,29 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.android.common.core.simpleSharedFlow
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteService
|
||||
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 no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
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.
|
||||
|
|
@ -61,12 +57,18 @@ class BleConnection(
|
|||
var peripheral: Peripheral? = null
|
||||
private set
|
||||
|
||||
private val _peripheral = MutableSharedFlow<Peripheral?>(replay = 1)
|
||||
|
||||
/** A flow of the current peripheral. */
|
||||
val peripheralFlow = _peripheral.asSharedFlow()
|
||||
|
||||
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
|
||||
private var profileJob: Job? = null
|
||||
|
||||
/**
|
||||
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
|
||||
|
|
@ -77,6 +79,7 @@ class BleConnection(
|
|||
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
|
||||
stateJob?.cancel()
|
||||
peripheral = p
|
||||
_peripheral.emit(p)
|
||||
|
||||
centralManager.connect(
|
||||
peripheral = p,
|
||||
|
|
@ -103,57 +106,32 @@ class BleConnection(
|
|||
*
|
||||
* @param p The peripheral to connect to.
|
||||
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
|
||||
* @param onRegister Optional block to run before connecting, allowing for profile registration.
|
||||
* @return The final [ConnectionState].
|
||||
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
|
||||
*/
|
||||
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
|
||||
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long, onRegister: suspend () -> Unit = {}): ConnectionState {
|
||||
onRegister()
|
||||
connect(p)
|
||||
return withTimeout(timeoutMs) {
|
||||
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
|
||||
}
|
||||
}
|
||||
|
||||
/** A flow of discovered services. Useful for reacting to "Service Changed" indications. */
|
||||
val services: SharedFlow<List<RemoteService>> =
|
||||
_connectionState
|
||||
.asSharedFlow()
|
||||
.filter { it is ConnectionState.Connected }
|
||||
.flatMapLatest { peripheral?.services() ?: flowOf(emptyList()) }
|
||||
.filterNotNull()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
|
||||
|
||||
/** Discovers characteristics for a specific service. */
|
||||
suspend fun discoverCharacteristics(
|
||||
serviceUuid: Uuid,
|
||||
requiredUuids: List<Uuid>,
|
||||
optionalUuids: List<Uuid> = emptyList(),
|
||||
): Map<Uuid, RemoteCharacteristic>? {
|
||||
val p = peripheral ?: return null
|
||||
|
||||
return retryBleOperation(tag = tag) {
|
||||
val allRequested = requiredUuids + optionalUuids
|
||||
val serviceList =
|
||||
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
|
||||
val service = serviceList.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
|
||||
|
||||
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
|
||||
for (uuid in allRequested) {
|
||||
val char = service.characteristics.find { it.uuid == uuid }
|
||||
if (char != null) {
|
||||
result[uuid] = char
|
||||
}
|
||||
}
|
||||
|
||||
val hasAllRequired = requiredUuids.all { result.containsKey(it) }
|
||||
if (hasAllRequired) result else null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
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" } }
|
||||
.onEach { params ->
|
||||
Logger.i { "[$tag] BLE connection parameters changed to $params" }
|
||||
try {
|
||||
val maxWriteLen = p.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$tag] Negotiated MTU (Write): $maxWriteLen bytes" }
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "[$tag] Could not read MTU: ${e.message}" }
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +139,65 @@ class BleConnection(
|
|||
suspend fun disconnect() = withContext(NonCancellable) {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
profileJob?.cancel()
|
||||
profileJob = null
|
||||
peripheral?.disconnect()
|
||||
peripheral = null
|
||||
_peripheral.emit(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a block within a discovered profile. Handles peripheral readiness, discovery with a timeout, and cleans
|
||||
* up the profile job if discovery fails.
|
||||
*
|
||||
* @param serviceUuid The UUID of the service to discover.
|
||||
* @param timeout The duration to wait for discovery.
|
||||
* @param block The block to execute with the discovered service.
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
suspend fun <T> profile(
|
||||
serviceUuid: Uuid,
|
||||
timeout: kotlin.time.Duration = 10.seconds,
|
||||
setup: suspend CoroutineScope.(no.nordicsemi.kotlin.ble.client.RemoteService) -> T,
|
||||
): T {
|
||||
val p = peripheralFlow.first { it != null }!!
|
||||
val serviceReady = CompletableDeferred<T>()
|
||||
|
||||
profileJob?.cancel()
|
||||
val job =
|
||||
scope.launch {
|
||||
try {
|
||||
val profileScope = this
|
||||
p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
|
||||
try {
|
||||
val result = setup(service)
|
||||
serviceReady.complete(result)
|
||||
// Keep the profile active until this launch scope (profileJob) is cancelled
|
||||
awaitCancellation()
|
||||
} catch (e: Throwable) {
|
||||
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
profileJob = job
|
||||
|
||||
return try {
|
||||
withTimeout(timeout) { serviceReady.await() }
|
||||
} catch (e: Throwable) {
|
||||
profileJob?.cancel()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the maximum write value length for the given write type. */
|
||||
fun maximumWriteValueLength(writeType: WriteType): Int? = peripheral?.maximumWriteValueLength(writeType)
|
||||
|
||||
/** Requests a new connection priority for the current peripheral. */
|
||||
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
|
||||
peripheral?.requestConnectionPriority(priority)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,12 +23,12 @@ 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 org.meshtastic.core.di.CoroutineDispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -47,5 +47,6 @@ object BleModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBleSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
|
||||
CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import kotlinx.coroutines.delay
|
|||
* @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,
|
||||
|
|
@ -43,7 +42,7 @@ suspend fun <T> retryBleOperation(
|
|||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
currentAttempt++
|
||||
if (currentAttempt >= count) {
|
||||
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ 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.RemoteServices
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
|
|
@ -85,13 +86,7 @@ constructor(
|
|||
}
|
||||
|
||||
internal suspend fun updateBluetoothState() {
|
||||
val hasPerms =
|
||||
if (androidEnvironment.requiresBluetoothRuntimePermissions) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
val hasPerms = hasRequiredPermissions()
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val newState =
|
||||
BluetoothState(
|
||||
|
|
@ -116,13 +111,7 @@ constructor(
|
|||
@SuppressLint("MissingPermission")
|
||||
fun isBonded(address: String): Boolean {
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val hasPerms =
|
||||
if (androidEnvironment.requiresBluetoothRuntimePermissions) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
val hasPerms = hasRequiredPermissions()
|
||||
return if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().any { it.address == address }
|
||||
} else {
|
||||
|
|
@ -130,10 +119,19 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun hasRequiredPermissions(): Boolean = if (androidEnvironment.requiresBluetoothRuntimePermissions) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
|
||||
/** 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
|
||||
val hasRequiredService =
|
||||
(peripheral.services(listOf(SERVICE_UUID)).value as? RemoteServices.Discovered)?.services?.isNotEmpty()
|
||||
?: false
|
||||
|
||||
return nameMatches || hasRequiredService
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,4 +39,15 @@ object MeshtasticBleConstants {
|
|||
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||
|
||||
val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
|
||||
|
||||
// --- OTA Characteristics ---
|
||||
|
||||
/** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
|
||||
val OTA_SERVICE_UUID: Uuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
|
||||
/** Characteristic for writing OTA commands and firmware data. */
|
||||
val OTA_WRITE_CHARACTERISTIC: Uuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130005")
|
||||
|
||||
/** Characteristic for receiving OTA status notifications/ACKs. */
|
||||
val OTA_NOTIFY_CHARACTERISTIC: Uuid = Uuid.parse("62ec0272-3ec5-11eb-b378-0242ac130003")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,4 +124,37 @@ class BluetoothRepositoryTest {
|
|||
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
|
||||
assertEquals(address, state.bondedDevices.first().address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isBonded returns false when permissions are not granted`() = runTest(testDispatcher) {
|
||||
val noPermsEnv =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = false,
|
||||
isBluetoothConnectPermissionGranted = false,
|
||||
)
|
||||
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
|
||||
runCurrent()
|
||||
|
||||
assertFalse(repository.isBonded("C0:00:00:00:00:03"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state has no permissions when bluetooth permissions denied`() = runTest(testDispatcher) {
|
||||
val noPermsEnv =
|
||||
MockAndroidEnvironment.Api31(
|
||||
isBluetoothEnabled = true,
|
||||
isBluetoothScanPermissionGranted = true,
|
||||
isBluetoothConnectPermissionGranted = false,
|
||||
)
|
||||
val centralManager = CentralManager.mock(noPermsEnv, backgroundScope)
|
||||
|
||||
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, noPermsEnv)
|
||||
runCurrent()
|
||||
|
||||
val state = repository.state.value
|
||||
assertFalse("hasPermissions should be false when connect permission is denied", state.hasPermissions)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue