mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: implement XModem file transfers and enhance BLE connection robustness (#4959)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
This commit is contained in:
parent
ae4465d7c8
commit
c75c9b34d6
43 changed files with 1100 additions and 120 deletions
|
|
@ -173,9 +173,23 @@ class AndroidBluetoothRepository(
|
|||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun getBondedAppPeripherals(): List<BleDevice> = bluetoothAdapter?.bondedDevices?.map { device ->
|
||||
deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
|
||||
} ?: emptyList()
|
||||
private fun getBondedAppPeripherals(): List<BleDevice> {
|
||||
val bonded = bluetoothAdapter?.bondedDevices ?: return emptyList()
|
||||
val bondedAddresses = bonded.mapTo(mutableSetOf()) { it.address }
|
||||
// Evict entries for devices that are no longer bonded and update names in case the
|
||||
// user renamed the device in firmware since the cache was populated.
|
||||
deviceCache.keys.retainAll(bondedAddresses)
|
||||
return bonded.map { device ->
|
||||
deviceCache
|
||||
.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
|
||||
.also { cached ->
|
||||
// Refresh name if it changed (firmware rename, etc.)
|
||||
if (cached.name != device.name) {
|
||||
deviceCache[device.address] = DirectBleDevice(device.address, device.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun isBonded(address: String): Boolean = try {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.AndroidPeripheral
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
import com.juul.kable.toIdentifier
|
||||
|
|
@ -43,3 +44,8 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
|
|||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? {
|
||||
val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null
|
||||
return (mtu - 3).takeIf { it > 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,7 @@ interface BleConnection {
|
|||
suspend fun connect(device: BleDevice)
|
||||
|
||||
/** Connects to the given [BleDevice] and waits for a terminal state. */
|
||||
suspend fun connectAndAwait(
|
||||
device: BleDevice,
|
||||
timeoutMs: Long,
|
||||
onRegister: suspend () -> Unit = {},
|
||||
): BleConnectionState
|
||||
suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState
|
||||
|
||||
/** Disconnects from the current device. */
|
||||
suspend fun disconnect()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import kotlinx.coroutines.CancellationException
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
|
@ -49,6 +50,7 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
|
||||
private val _connectionState =
|
||||
MutableSharedFlow<BleConnectionState>(
|
||||
replay = 1,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
|
@ -121,22 +123,19 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
}
|
||||
|
||||
@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 connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) {
|
||||
connect(device)
|
||||
BleConnectionState.Connected
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
// Our own timeout expired — treat as a failed attempt so callers can retry.
|
||||
BleConnectionState.Disconnected
|
||||
} catch (e: CancellationException) {
|
||||
// External cancellation (scope closed) — must propagate.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
|
||||
override suspend fun disconnect() = withContext(NonCancellable) {
|
||||
|
|
@ -164,8 +163,5 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
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
|
||||
}
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() ?: 512
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,12 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
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.
|
||||
// Scanned devices can be connected directly without an explicit bonding step.
|
||||
// On Android, Kable's connectGatt triggers the OS pairing dialog transparently
|
||||
// when the firmware requires an encrypted link. On Desktop, btleplug delegates
|
||||
// to the OS Bluetooth stack which handles pairing the same way.
|
||||
// The BleRadioInterface.connect() reconnection path has a separate isBonded
|
||||
// check for the case where a previously bonded device loses its bond.
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
|
|
@ -48,7 +52,8 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// Not supported/needed on jvmMain desktop currently
|
||||
// Bonding for scanned devices is handled at the BluetoothRepository level
|
||||
// (Android) or by the OS during GATT connection (Desktop/JVM).
|
||||
}
|
||||
|
||||
internal fun updateState(newState: BleConnectionState) {
|
||||
|
|
|
|||
|
|
@ -26,15 +26,20 @@ import kotlin.uuid.Uuid
|
|||
class KableBleScanner : BleScanner {
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
||||
val scanner = Scanner {
|
||||
// When both serviceUuid and address are provided (the findDevice reconnect path),
|
||||
// filter by service UUID only. The caller applies address filtering post-collection.
|
||||
// Using a single match{} with both creates an AND filter that silently drops results
|
||||
// on some OEM BLE stacks (Samsung, Xiaomi) when the device uses a random resolvable
|
||||
// private address. Using separate match{} blocks creates OR semantics which would
|
||||
// return all Meshtastic devices, so we only filter by service UUID in that case.
|
||||
if (serviceUuid != null || address != null) {
|
||||
filters {
|
||||
match {
|
||||
if (serviceUuid != null) {
|
||||
services = listOf(serviceUuid)
|
||||
}
|
||||
if (address != null) {
|
||||
this.address = address
|
||||
}
|
||||
if (serviceUuid != null) {
|
||||
match { services = listOf(serviceUuid) }
|
||||
} else if (address != null) {
|
||||
// Address-only scan (no service UUID filter). BLE MAC addresses are
|
||||
// normalized to uppercase on Android; uppercase() covers any edge cases.
|
||||
match { this.address = address.uppercase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import com.juul.kable.Peripheral
|
|||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
|
|
@ -41,7 +43,13 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
|
||||
|
||||
private val triggerDrain = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
|
||||
// replay = 1: a seed emission placed here before the collector starts is replayed to the
|
||||
// collector immediately on subscription. This is what drives the initial FROMRADIO poll
|
||||
// during the config-handshake phase, where the firmware suppresses FROMNUM notifications
|
||||
// (it only emits them in STATE_SEND_PACKETS). Without the initial replay the entire config
|
||||
// stream would be silently skipped on devices that lack FROMRADIOSYNC.
|
||||
private val triggerDrain =
|
||||
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
init {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
|
|
@ -68,13 +76,21 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
} else {
|
||||
error("fromRadioSync missing")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Fallback to legacy
|
||||
// Fallback to legacy FROMNUM/FROMRADIO polling.
|
||||
// Wire up FROMNUM notifications for steady-state packet delivery.
|
||||
launch {
|
||||
if (hasCharacteristic(FROMNUM_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
}
|
||||
}
|
||||
// Seed the replay buffer so the collector below starts draining immediately.
|
||||
// The firmware does NOT send FROMNUM notifications during the config handshake
|
||||
// (it gates them on STATE_SEND_PACKETS). Without this seed the entire config
|
||||
// stream would never be read on devices that lack FROMRADIOSYNC.
|
||||
triggerDrain.tryEmit(Unit)
|
||||
triggerDrain.collect {
|
||||
var keepReading = true
|
||||
while (keepReading) {
|
||||
|
|
|
|||
|
|
@ -24,3 +24,9 @@ internal expect fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
|
|||
|
||||
/** Platform-specific instantiation of a Peripheral by address. */
|
||||
internal expect fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral
|
||||
|
||||
/**
|
||||
* Returns the negotiated maximum write payload length in bytes (i.e. ATT MTU minus the 3-byte ATT header), or `null` if
|
||||
* MTU has not yet been negotiated on this platform.
|
||||
*/
|
||||
internal expect fun Peripheral.negotiatedMaxWriteLength(): Int?
|
||||
|
|
|
|||
|
|
@ -26,3 +26,5 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
|
|||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
throw UnsupportedOperationException("iOS Peripheral not yet implemented")
|
||||
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null
|
||||
|
|
|
|||
|
|
@ -26,3 +26,6 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
|
|||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
||||
// JVM/desktop Kable does not expose an MTU StateFlow; fall back to null so callers use their default.
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? = null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue