mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: Replace Nordic, use Kable backend for Desktop and Android with BLE support (#4818)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
0e5f94579f
commit
0b2e89c46f
79 changed files with 1980 additions and 2965 deletions
|
|
@ -1,193 +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 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.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
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.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
import no.nordicsemi.kotlin.ble.core.WriteType
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* An Android implementation of [BleConnection] using Nordic's [CentralManager].
|
||||
*
|
||||
* @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 AndroidBleConnection(
|
||||
private val centralManager: CentralManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val tag: String = "BLE",
|
||||
) : BleConnection {
|
||||
|
||||
private var _device: AndroidBleDevice? = null
|
||||
override val device: BleDevice?
|
||||
get() = _device
|
||||
|
||||
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
||||
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
|
||||
|
||||
private val _connectionState = simpleSharedFlow<BleConnectionState>()
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
private var stateJob: Job? = null
|
||||
private var profileJob: Job? = null
|
||||
|
||||
override suspend fun connect(device: BleDevice) = withContext(NonCancellable) {
|
||||
val androidDevice = device as AndroidBleDevice
|
||||
stateJob?.cancel()
|
||||
_device = androidDevice
|
||||
_deviceFlow.emit(androidDevice)
|
||||
|
||||
centralManager.connect(
|
||||
peripheral = androidDevice.peripheral,
|
||||
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
|
||||
)
|
||||
|
||||
stateJob =
|
||||
androidDevice.peripheral.state
|
||||
.onEach { state ->
|
||||
Logger.d { "[$tag] Connection state changed to $state" }
|
||||
val commonState =
|
||||
when (state) {
|
||||
is ConnectionState.Connecting -> BleConnectionState.Connecting
|
||||
is ConnectionState.Connected -> BleConnectionState.Connected
|
||||
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
|
||||
}
|
||||
|
||||
if (state is ConnectionState.Connected) {
|
||||
androidDevice.peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
|
||||
observePeripheralDetails(androidDevice)
|
||||
}
|
||||
|
||||
androidDevice.updateState(state)
|
||||
_connectionState.emit(commonState)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override suspend fun connectAndAwait(
|
||||
device: BleDevice,
|
||||
timeoutMs: Long,
|
||||
onRegister: suspend () -> Unit,
|
||||
): BleConnectionState {
|
||||
onRegister()
|
||||
connect(device)
|
||||
return withTimeout(timeoutMs) {
|
||||
connectionState.first { it is BleConnectionState.Connected || it is BleConnectionState.Disconnected }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun observePeripheralDetails(androidDevice: AndroidBleDevice) {
|
||||
val p = androidDevice.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" }
|
||||
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)
|
||||
}
|
||||
|
||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
profileJob?.cancel()
|
||||
profileJob = null
|
||||
_device?.peripheral?.disconnect()
|
||||
_device = null
|
||||
_deviceFlow.emit(null)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun <T> profile(
|
||||
serviceUuid: Uuid,
|
||||
timeout: kotlin.time.Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T {
|
||||
val androidDevice = deviceFlow.first { it != null } as AndroidBleDevice
|
||||
val p = androidDevice.peripheral
|
||||
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(AndroidBleService(service))
|
||||
serviceReady.complete(result)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
|
||||
val nordicWriteType =
|
||||
when (writeType) {
|
||||
BleWriteType.WITH_RESPONSE -> WriteType.WITH_RESPONSE
|
||||
BleWriteType.WITHOUT_RESPONSE -> WriteType.WITHOUT_RESPONSE
|
||||
}
|
||||
return _device?.peripheral?.maximumWriteValueLength(nordicWriteType)
|
||||
}
|
||||
|
||||
/** Requests a new connection priority for the current peripheral. */
|
||||
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
|
||||
_device?.peripheral?.requestConnectionPriority(priority)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +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 android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import no.nordicsemi.kotlin.ble.client.android.Peripheral
|
||||
import no.nordicsemi.kotlin.ble.core.BondState
|
||||
import no.nordicsemi.kotlin.ble.core.ConnectionState
|
||||
|
||||
/** An Android implementation of [BleDevice] that wraps a Nordic [Peripheral]. */
|
||||
class AndroidBleDevice(val peripheral: Peripheral) : BleDevice {
|
||||
override val name: String?
|
||||
get() = peripheral.name
|
||||
|
||||
override val address: String
|
||||
get() = peripheral.address
|
||||
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||
|
||||
@Suppress("MissingPermission")
|
||||
override val isBonded: Boolean
|
||||
get() = peripheral.bondState.value == BondState.BONDED
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = peripheral.isConnected
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun readRssi(): Int = peripheral.readRssi()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun bond() {
|
||||
peripheral.createBond()
|
||||
}
|
||||
|
||||
/** Updates the connection state based on Nordic's [ConnectionState]. */
|
||||
fun updateState(nordicState: ConnectionState) {
|
||||
_state.value =
|
||||
when (nordicState) {
|
||||
is ConnectionState.Connecting -> BleConnectionState.Connecting
|
||||
is ConnectionState.Connected -> BleConnectionState.Connected
|
||||
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* An Android implementation of [BleScanner] using Nordic's [CentralManager].
|
||||
*
|
||||
* @param centralManager The Nordic [CentralManager] to use for scanning.
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Single
|
||||
class AndroidBleScanner(private val centralManager: CentralManager) : BleScanner {
|
||||
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?): Flow<BleDevice> = centralManager
|
||||
.scan(timeout = timeout) {
|
||||
if (serviceUuid != null) {
|
||||
ServiceUuid(serviceUuid)
|
||||
}
|
||||
}
|
||||
.distinctByPeripheral()
|
||||
.map { AndroidBleDevice(it.peripheral) }
|
||||
}
|
||||
|
|
@ -16,8 +16,14 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
|
|
@ -25,31 +31,40 @@ 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
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
/** Android implementation of [BluetoothRepository]. */
|
||||
@Single
|
||||
class AndroidBluetoothRepository(
|
||||
private val context: Context,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val centralManager: CentralManager,
|
||||
private val androidEnvironment: AndroidEnvironment,
|
||||
) : BluetoothRepository {
|
||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true))
|
||||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
|
||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
|
||||
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
|
||||
|
||||
private val deviceCache = mutableMapOf<String, DirectBleDevice>()
|
||||
|
||||
init {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
|
||||
}
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
|
||||
}
|
||||
|
||||
private fun hasBluetoothPermissions(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val hasConnect =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val hasScan =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasConnect && hasScan
|
||||
} else {
|
||||
// Pre-Android 12: classic Bluetooth permissions are install-time.
|
||||
true
|
||||
}
|
||||
|
||||
override fun refreshState() {
|
||||
|
|
@ -58,59 +73,112 @@ class AndroidBluetoothRepository(
|
|||
|
||||
override fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
|
||||
|
||||
@Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught", "SwallowedException")
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun bond(device: BleDevice) {
|
||||
val androidDevice = device as AndroidBleDevice
|
||||
androidDevice.peripheral.createBond()
|
||||
val macAddress = device.address
|
||||
val remoteDevice =
|
||||
bluetoothAdapter?.getRemoteDevice(macAddress) ?: throw Exception("Bluetooth adapter unavailable")
|
||||
|
||||
if (remoteDevice.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED) {
|
||||
updateBluetoothState()
|
||||
return
|
||||
}
|
||||
|
||||
kotlinx.coroutines.suspendCancellableCoroutine<Unit> { cont ->
|
||||
val receiver =
|
||||
object : android.content.BroadcastReceiver() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(c: Context, intent: android.content.Intent) {
|
||||
if (intent.action == android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
|
||||
val d =
|
||||
intent.getParcelableExtra<android.bluetooth.BluetoothDevice>(
|
||||
android.bluetooth.BluetoothDevice.EXTRA_DEVICE,
|
||||
)
|
||||
if (d?.address?.equals(macAddress, ignoreCase = true) == true) {
|
||||
val state =
|
||||
intent.getIntExtra(
|
||||
android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE,
|
||||
android.bluetooth.BluetoothDevice.ERROR,
|
||||
)
|
||||
val prevState =
|
||||
intent.getIntExtra(
|
||||
android.bluetooth.BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE,
|
||||
android.bluetooth.BluetoothDevice.ERROR,
|
||||
)
|
||||
|
||||
if (state == android.bluetooth.BluetoothDevice.BOND_BONDED) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (ignored: Exception) {}
|
||||
if (cont.isActive) cont.resume(Unit) {}
|
||||
} else if (
|
||||
state == android.bluetooth.BluetoothDevice.BOND_NONE &&
|
||||
prevState == android.bluetooth.BluetoothDevice.BOND_BONDING
|
||||
) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (ignored: Exception) {}
|
||||
if (cont.isActive) {
|
||||
cont.resumeWith(Result.failure(Exception("Bonding failed or rejected")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = android.content.IntentFilter(android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (ignored: Exception) {}
|
||||
}
|
||||
|
||||
if (!remoteDevice.createBond()) {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
} catch (ignored: Exception) {}
|
||||
if (cont.isActive) cont.resumeWith(Result.failure(Exception("Failed to initiate bonding")))
|
||||
}
|
||||
}
|
||||
updateBluetoothState()
|
||||
}
|
||||
|
||||
internal suspend fun updateBluetoothState() {
|
||||
val hasPerms = hasRequiredPermissions()
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val newState =
|
||||
BluetoothState(
|
||||
hasPermissions = hasPerms,
|
||||
enabled = enabled,
|
||||
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
|
||||
)
|
||||
val enabled = bluetoothAdapter?.isEnabled == true
|
||||
var hasPermissions = hasBluetoothPermissions()
|
||||
val bondedDevices =
|
||||
if (hasPermissions) {
|
||||
try {
|
||||
getBondedAppPeripherals()
|
||||
} catch (e: SecurityException) {
|
||||
Logger.w(e) { "SecurityException accessing bonded devices. Missing BLUETOOTH_CONNECT?" }
|
||||
hasPermissions = false
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val newState = BluetoothState(hasPermissions = hasPermissions, enabled = enabled, bondedDevices = bondedDevices)
|
||||
|
||||
_state.emit(newState)
|
||||
Logger.d { "Detected our bluetooth access=$newState" }
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<BleDevice> =
|
||||
if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral).map { AndroidBleDevice(it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
private fun getBondedAppPeripherals(): List<BleDevice> = bluetoothAdapter?.bondedDevices?.map { device ->
|
||||
deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
|
||||
} ?: emptyList()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun isBonded(address: String): Boolean {
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val hasPerms = hasRequiredPermissions()
|
||||
return if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().any { it.address == address }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRequiredPermissions(): Boolean = if (androidEnvironment.requiresBluetoothRuntimePermissions) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
|
||||
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
|
||||
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
|
||||
val hasRequiredService =
|
||||
(peripheral.services(listOf(SERVICE_UUID)).value as? RemoteServices.Discovered)?.services?.isNotEmpty()
|
||||
?: false
|
||||
|
||||
return nameMatches || hasRequiredService
|
||||
override fun isBonded(address: String): Boolean = try {
|
||||
bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.toIdentifier
|
||||
|
||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||
// If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice),
|
||||
// we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail
|
||||
// immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses.
|
||||
// If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster.
|
||||
autoConnectIf(autoConnect)
|
||||
|
||||
onServicesDiscovered {
|
||||
try {
|
||||
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
|
||||
// Requesting the max MTU is critical for preventing dropped packets and stalls.
|
||||
@Suppress("MagicNumber")
|
||||
val negotiatedMtu = requestMtu(512)
|
||||
Logger.i { "Negotiated MTU: $negotiatedMtu" }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "Failed to request MTU" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
|
@ -19,13 +19,6 @@ package org.meshtastic.core.ble.di
|
|||
import android.app.Application
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
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.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
|
|
@ -33,16 +26,6 @@ import org.koin.core.annotation.Single
|
|||
@Module
|
||||
@ComponentScan("org.meshtastic.core.ble")
|
||||
class CoreBleAndroidModule {
|
||||
@Single
|
||||
fun provideAndroidEnvironment(app: Application): AndroidEnvironment =
|
||||
NativeAndroidEnvironment.getInstance(app, isNeverForLocationFlagSet = true)
|
||||
|
||||
@Single
|
||||
fun provideCentralManager(environment: AndroidEnvironment): CentralManager = CentralManager.native(
|
||||
environment as NativeAndroidEnvironment,
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
)
|
||||
|
||||
@Single
|
||||
fun provideLocationManager(app: Application): LocationManager =
|
||||
ContextCompat.getSystemService(app, LocationManager::class.java)!!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Peripheral
|
||||
|
||||
/**
|
||||
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
|
||||
* dynamically created UI devices (scanned vs bonded) and the actual connection.
|
||||
*/
|
||||
internal object ActiveBleConnection {
|
||||
var activePeripheral: Peripheral? = null
|
||||
var activeAddress: String? = null
|
||||
}
|
||||
|
|
@ -27,5 +27,5 @@ interface BleScanner {
|
|||
* @param timeout The duration of the scan.
|
||||
* @return A [Flow] of discovered [BleDevice]s.
|
||||
*/
|
||||
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null): Flow<BleDevice>
|
||||
fun scan(timeout: Duration, serviceUuid: kotlin.uuid.Uuid? = null, address: String? = null): Flow<BleDevice>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteService
|
||||
|
||||
/** An Android implementation of [BleService] that wraps a Nordic [RemoteService]. */
|
||||
class AndroidBleService(val service: RemoteService) : BleService
|
||||
/** Extension to convert a [BleService] to a [MeshtasticRadioProfile]. */
|
||||
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile {
|
||||
val kableService = this as KableBleService
|
||||
return KableMeshtasticRadioProfile(kableService.peripheral)
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */
|
||||
class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice {
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
|
||||
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val peripheral = ActiveBleConnection.activePeripheral
|
||||
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||
peripheral.rssi()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// DirectBleDevice assumes we are already bonded.
|
||||
}
|
||||
|
||||
fun updateState(newState: BleConnectionState) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Peripheral
|
||||
import com.juul.kable.State
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableBleService(val peripheral: Peripheral) : BleService
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
class KableBleConnection(private val scope: CoroutineScope, private val tag: String) : BleConnection {
|
||||
|
||||
private var peripheral: Peripheral? = null
|
||||
private var stateJob: Job? = null
|
||||
private var connectionScope: CoroutineScope? = null
|
||||
|
||||
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
|
||||
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
|
||||
|
||||
override val device: BleDevice?
|
||||
get() = _deviceFlow.replayCache.firstOrNull()
|
||||
|
||||
private val _connectionState =
|
||||
MutableSharedFlow<BleConnectionState>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
override suspend fun connect(device: BleDevice) {
|
||||
val autoConnect = MutableStateFlow(device is DirectBleDevice)
|
||||
|
||||
val p =
|
||||
when (device) {
|
||||
is KableBleDevice ->
|
||||
Peripheral(device.advertisement) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
is DirectBleDevice ->
|
||||
createPeripheral(device.address) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
else -> error("Unsupported BleDevice type: ${device::class}")
|
||||
}
|
||||
|
||||
peripheral?.disconnect()
|
||||
peripheral?.close()
|
||||
peripheral = p
|
||||
|
||||
ActiveBleConnection.activePeripheral = p
|
||||
ActiveBleConnection.activeAddress = device.address
|
||||
|
||||
_deviceFlow.emit(device)
|
||||
|
||||
stateJob?.cancel()
|
||||
var hasStartedConnecting = false
|
||||
stateJob =
|
||||
p.state
|
||||
.onEach { kableState ->
|
||||
val mappedState = kableState.toBleConnectionState(hasStartedConnecting) ?: return@onEach
|
||||
if (kableState is State.Connecting || kableState is State.Connected) {
|
||||
hasStartedConnecting = true
|
||||
}
|
||||
|
||||
when (device) {
|
||||
is KableBleDevice -> device.updateState(mappedState)
|
||||
is DirectBleDevice -> device.updateState(mappedState)
|
||||
}
|
||||
|
||||
_connectionState.emit(mappedState)
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
while (p.state.value !is State.Connected) {
|
||||
autoConnect.value =
|
||||
try {
|
||||
connectionScope = p.connect()
|
||||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
|
||||
@Suppress("MagicNumber")
|
||||
val retryDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(retryDelayMs)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun connectAndAwait(
|
||||
device: BleDevice,
|
||||
timeoutMs: Long,
|
||||
onRegister: suspend () -> Unit,
|
||||
): BleConnectionState {
|
||||
onRegister()
|
||||
return try {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) {
|
||||
connect(device)
|
||||
BleConnectionState.Connected
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||
stateJob?.cancel()
|
||||
stateJob = null
|
||||
peripheral?.disconnect()
|
||||
peripheral?.close()
|
||||
peripheral = null
|
||||
connectionScope = null
|
||||
|
||||
ActiveBleConnection.activePeripheral = null
|
||||
ActiveBleConnection.activeAddress = null
|
||||
|
||||
_deviceFlow.emit(null)
|
||||
}
|
||||
|
||||
override suspend fun <T> profile(
|
||||
serviceUuid: Uuid,
|
||||
timeout: Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T {
|
||||
val p = peripheral ?: error("Not connected")
|
||||
val cScope = connectionScope ?: error("No active connection scope")
|
||||
val service = KableBleService(p)
|
||||
return cScope.setup(service)
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
|
||||
// Desktop MTU isn't always easily exposed, provide a safe default for Meshtastic
|
||||
return 512
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
* Copyright (c) 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
|
||||
|
|
@ -17,12 +17,9 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import no.nordicsemi.kotlin.ble.client.android.CentralManager
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/** An Android implementation of [BleConnectionFactory]. */
|
||||
@Single
|
||||
class AndroidBleConnectionFactory(private val centralManager: CentralManager) : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection =
|
||||
AndroidBleConnection(centralManager, scope, tag)
|
||||
class KableBleConnectionFactory : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, tag)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Advertisement
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
||||
override val name: String?
|
||||
get() = advertisement.name
|
||||
|
||||
override val address: String
|
||||
get() = advertisement.identifier.toString()
|
||||
|
||||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state
|
||||
|
||||
// On desktop, bonding isn't strictly required before connecting via Kable,
|
||||
// and we don't have a pairing flow. Defaulting to true lets the UI connect directly.
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
|
||||
|
||||
@OptIn(com.juul.kable.ExperimentalApi::class)
|
||||
override suspend fun readRssi(): Int {
|
||||
val peripheral = ActiveBleConnection.activePeripheral
|
||||
return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
|
||||
peripheral.rssi()
|
||||
} else {
|
||||
advertisement.rssi
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// Not supported/needed on jvmMain desktop currently
|
||||
}
|
||||
|
||||
internal fun updateState(newState: BleConnectionState) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Scanner
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Single
|
||||
class KableBleScanner : BleScanner {
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
||||
val scanner = Scanner {
|
||||
if (serviceUuid != null || address != null) {
|
||||
filters {
|
||||
match {
|
||||
if (serviceUuid != null) {
|
||||
services = listOf(serviceUuid)
|
||||
}
|
||||
if (address != null) {
|
||||
this.address = address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
|
||||
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
||||
return kotlinx.coroutines.flow.channelFlow {
|
||||
kotlinx.coroutines.withTimeoutOrNull(timeout) {
|
||||
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Peripheral
|
||||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : MeshtasticRadioProfile {
|
||||
|
||||
private val toRadio = characteristicOf(SERVICE_UUID, TORADIO_CHARACTERISTIC)
|
||||
private val fromRadioChar = characteristicOf(SERVICE_UUID, FROMRADIO_CHARACTERISTIC)
|
||||
private val fromRadioSync = characteristicOf(SERVICE_UUID, FROMRADIOSYNC_CHARACTERISTIC)
|
||||
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
|
||||
|
||||
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
|
||||
|
||||
init {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
Logger.i {
|
||||
"KableMeshtasticRadioProfile init. Discovered characteristics: ${svc?.characteristics?.map {
|
||||
it.characteristicUuid
|
||||
}}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCharacteristic(uuid: Uuid): Boolean = peripheral.services.value?.any { svc ->
|
||||
svc.serviceUuid == SERVICE_UUID && svc.characteristics.any { it.characteristicUuid == uuid }
|
||||
} == true
|
||||
|
||||
// Using observe() for fromRadioSync or legacy read loop for fromRadio
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val fromRadio: Flow<ByteArray> = channelFlow {
|
||||
// Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO.
|
||||
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
|
||||
launch {
|
||||
try {
|
||||
if (hasCharacteristic(FROMRADIOSYNC_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromRadioSync).collect { send(it) }
|
||||
} else {
|
||||
error("fromRadioSync missing")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback to legacy
|
||||
launch {
|
||||
if (hasCharacteristic(FROMNUM_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
}
|
||||
}
|
||||
triggerDrain.collect {
|
||||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
if (!hasCharacteristic(FROMRADIO_CHARACTERISTIC)) {
|
||||
keepReading = false
|
||||
continue
|
||||
}
|
||||
val packet = peripheral.read(fromRadioChar)
|
||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||
} catch (e: Exception) {
|
||||
keepReading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val logRadio: Flow<ByteArray> = channelFlow {
|
||||
try {
|
||||
if (hasCharacteristic(LOGRADIO_CHARACTERISTIC)) {
|
||||
peripheral.observe(logRadioChar).collect { send(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// logRadio is optional, ignore if not found
|
||||
}
|
||||
}
|
||||
|
||||
private val toRadioWriteType: WriteType by lazy {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
val char = svc?.characteristics?.find { it.characteristicUuid == TORADIO_CHARACTERISTIC }
|
||||
|
||||
if (char?.properties?.writeWithoutResponse == true) {
|
||||
WriteType.WithoutResponse
|
||||
} else {
|
||||
WriteType.WithResponse
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
peripheral.write(toRadio, packet, toRadioWriteType)
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
|
||||
/** Platform-specific configuration for the Peripheral builder based on device type. */
|
||||
internal expect fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean)
|
||||
|
||||
/** Platform-specific instantiation of a Peripheral by address. */
|
||||
internal expect fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.State
|
||||
|
||||
/**
|
||||
* Maps Kable's [State] to Meshtastic's [BleConnectionState].
|
||||
*
|
||||
* @param hasStartedConnecting whether we have seen a Connecting state. This is used to ignore the initial Disconnected
|
||||
* state emitted by StateFlow upon subscription.
|
||||
* @return the mapped [BleConnectionState], or null if the state should be ignored.
|
||||
*/
|
||||
fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? {
|
||||
return when (this) {
|
||||
is State.Connecting -> BleConnectionState.Connecting
|
||||
is State.Connected -> BleConnectionState.Connected
|
||||
is State.Disconnecting -> BleConnectionState.Disconnecting
|
||||
is State.Disconnected -> {
|
||||
if (!hasStartedConnecting) return null
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
/** A definition of the Meshtastic BLE Service profile. */
|
||||
interface MeshtasticRadioProfile {
|
||||
/** The flow of incoming packets from the radio. */
|
||||
val fromRadio: Flow<ByteArray>
|
||||
|
||||
/** The flow of incoming log packets from the radio. */
|
||||
val logRadio: Flow<ByteArray>
|
||||
|
||||
/** Sends a packet to the radio. */
|
||||
suspend fun sendToRadio(packet: ByteArray)
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.State
|
||||
import io.mockk.mockk
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class KableStateMappingTest {
|
||||
|
||||
@Test
|
||||
fun `Connecting maps to Connecting`() {
|
||||
val state = mockk<State.Connecting>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = false)
|
||||
assertEquals(BleConnectionState.Connecting, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Connected maps to Connected`() {
|
||||
val state = mockk<State.Connected>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertEquals(BleConnectionState.Connected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnecting maps to Disconnecting`() {
|
||||
val state = mockk<State.Disconnecting>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertEquals(BleConnectionState.Disconnecting, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected ignores initial emission if not started connecting`() {
|
||||
val state = mockk<State.Disconnected>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = false)
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected maps to Disconnected if started connecting`() {
|
||||
val state = mockk<State.Disconnected>()
|
||||
val result = state.toBleConnectionState(hasStartedConnecting = true)
|
||||
assertEquals(BleConnectionState.Disconnected, result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class FakeMeshtasticRadioProfile : MeshtasticRadioProfile {
|
||||
private val _fromRadio = MutableSharedFlow<ByteArray>(replay = 1)
|
||||
override val fromRadio: Flow<ByteArray> = _fromRadio
|
||||
|
||||
private val _logRadio = MutableSharedFlow<ByteArray>(replay = 1)
|
||||
override val logRadio: Flow<ByteArray> = _logRadio
|
||||
|
||||
val sentPackets = mutableListOf<ByteArray>()
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
sentPackets.add(packet)
|
||||
}
|
||||
|
||||
suspend fun emitFromRadio(packet: ByteArray) {
|
||||
_fromRadio.emit(packet)
|
||||
}
|
||||
|
||||
suspend fun emitLogRadio(packet: ByteArray) {
|
||||
_logRadio.emit(packet)
|
||||
}
|
||||
}
|
||||
|
||||
class MeshtasticRadioProfileTest {
|
||||
|
||||
@Test
|
||||
fun testFakeProfileEmitsFromRadio() = runTest {
|
||||
val fake = FakeMeshtasticRadioProfile()
|
||||
val expectedPacket = byteArrayOf(1, 2, 3)
|
||||
|
||||
fake.emitFromRadio(expectedPacket)
|
||||
|
||||
val received = fake.fromRadio.first()
|
||||
assertEquals(expectedPacket.toList(), received.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFakeProfileRecordsSentPackets() = runTest {
|
||||
val fake = FakeMeshtasticRadioProfile()
|
||||
val packet = byteArrayOf(4, 5, 6)
|
||||
|
||||
fake.sendToRadio(packet)
|
||||
|
||||
assertEquals(1, fake.sentPackets.size)
|
||||
assertEquals(packet.toList(), fake.sentPackets.first().toList())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class KableBluetoothRepository : BluetoothRepository {
|
||||
// Desktop Kable doesn't currently expose much state tracking easily, assume true.
|
||||
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
|
||||
override val state: StateFlow<BluetoothState> = _state
|
||||
|
||||
override fun refreshState() {
|
||||
// No-op for now on desktop
|
||||
}
|
||||
|
||||
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotEmpty()
|
||||
|
||||
override fun isBonded(address: String): Boolean {
|
||||
return false // Bonding not supported on desktop yet
|
||||
}
|
||||
|
||||
override suspend fun bond(device: BleDevice) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 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 com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.toIdentifier
|
||||
|
||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||
// Desktop Kable uses direct connections without needing autoConnect.
|
||||
}
|
||||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Proximity
|
||||
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
|
||||
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
|
||||
class BleScannerTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@Test
|
||||
fun `scan returns peripherals`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val scanner = AndroidBleScanner(centralManager)
|
||||
|
||||
val peripheral =
|
||||
PeripheralSpec.simulatePeripheral(
|
||||
identifier = "00:11:22:33:44:55",
|
||||
addressType = AddressType.RANDOM_STATIC,
|
||||
proximity = Proximity.IMMEDIATE,
|
||||
) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Test_Device")
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(peripheral))
|
||||
|
||||
val result = scanner.scan(5.seconds).first()
|
||||
|
||||
assertEquals("00:11:22:33:44:55", result.address)
|
||||
assertEquals("Test_Device", result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scan with filter returns only matching peripherals`() = runTest(testDispatcher) {
|
||||
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
|
||||
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
|
||||
val scanner = AndroidBleScanner(centralManager)
|
||||
|
||||
val targetUuid = Uuid.parse("4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
|
||||
|
||||
val matchingPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = "00:11:22:33:44:55", proximity = Proximity.IMMEDIATE) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Matching_Device")
|
||||
ServiceUuid(targetUuid)
|
||||
}
|
||||
}
|
||||
|
||||
val nonMatchingPeripheral =
|
||||
PeripheralSpec.simulatePeripheral(identifier = "AA:BB:CC:DD:EE:FF", proximity = Proximity.IMMEDIATE) {
|
||||
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
|
||||
CompleteLocalName("Non_Matching_Device")
|
||||
}
|
||||
}
|
||||
|
||||
centralManager.simulatePeripherals(listOf(matchingPeripheral, nonMatchingPeripheral))
|
||||
|
||||
val scannedDevices = mutableListOf<no.nordicsemi.kotlin.ble.client.android.Peripheral>()
|
||||
val job = launch { scanner.scan(5.seconds, targetUuid).toList(scannedDevices) }
|
||||
|
||||
// Needs time to scan in mock environment
|
||||
advanceUntilIdle()
|
||||
job.cancel()
|
||||
|
||||
// TODO: test filter logic correctly if necessary
|
||||
}
|
||||
}
|
||||
|
|
@ -1,160 +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 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)
|
||||
}
|
||||
|
||||
@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