mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix(transport): improve BLE / TCP / USB reconnect and handshake resilience (#5196)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
a90cb2d89e
commit
f21d8af9ae
14 changed files with 124 additions and 46 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -55,3 +55,4 @@ wireless-install.sh
|
|||
firebase-debug.log
|
||||
.agent_plans/
|
||||
.agent_refs/
|
||||
.agent_artifacts/
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
|
|||
import org.meshtastic.app.ui.MainScreen
|
||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.channel_invalid
|
||||
|
|
@ -91,6 +92,8 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen
|
|||
class MainActivity : ComponentActivity() {
|
||||
private val model: UIViewModel by viewModel()
|
||||
|
||||
private val usbRepository: UsbRepository by inject()
|
||||
|
||||
/**
|
||||
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
|
||||
* itself as a LifecycleObserver in its init block.
|
||||
|
|
@ -166,6 +169,16 @@ class MainActivity : ComponentActivity() {
|
|||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
|
||||
// resumed while a USB device is already attached (e.g. process restart, returning
|
||||
// from another app), the manifest-declared attach intent may have already fired
|
||||
// before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
|
||||
// reality without requiring the user to physically replug.
|
||||
usbRepository.refreshState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppCompositionLocals(content: @Composable () -> Unit) {
|
||||
CompositionLocalProvider(
|
||||
|
|
@ -257,6 +270,11 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
|
||||
Logger.d { "USB device attached" }
|
||||
// Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
|
||||
// receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
|
||||
// never sees this event. Forward it explicitly so the serialDevices StateFlow
|
||||
// refreshes and the device shows up in the Connect → Serial tab.
|
||||
usbRepository.refreshState()
|
||||
showSettingsPage()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
|
|||
/**
|
||||
* Classification of a BLE-layer exception for the transport layer to act on.
|
||||
*
|
||||
* @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
|
||||
* @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
|
||||
* Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission
|
||||
* grants, transient GATT errors). Reserved for future use.
|
||||
* @property gattStatus the platform GATT status code when available (Android-specific).
|
||||
* @property message a human-readable description of the failure.
|
||||
*/
|
||||
|
|
@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
|
|||
is GattRequestRejectedException ->
|
||||
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
|
||||
is UnmetRequirementException ->
|
||||
BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
|
||||
// Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
|
||||
// device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
|
||||
// retrying; UI can show a hint based on the message.
|
||||
BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import org.meshtastic.proto.AdminMessage
|
|||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.DurationUnit
|
||||
|
|
@ -211,11 +212,11 @@ class MeshConnectionManagerImpl(
|
|||
}
|
||||
}
|
||||
|
||||
private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
|
||||
private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout =
|
||||
scope.handledLaunch {
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
delay(timeout)
|
||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||
// Attempt one retry. Note: the firmware silently drops identical consecutive
|
||||
// writes (per-connection dedup). If the first want_config_id was received and
|
||||
|
|
@ -291,13 +292,13 @@ class MeshConnectionManagerImpl(
|
|||
|
||||
override fun startConfigOnly() {
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
|
||||
startHandshakeStallGuard(1, action)
|
||||
startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
|
||||
action()
|
||||
}
|
||||
|
||||
override fun startNodeInfoOnly() {
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
|
||||
startHandshakeStallGuard(2, action)
|
||||
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
|
||||
action()
|
||||
}
|
||||
|
||||
|
|
@ -404,7 +405,14 @@ class MeshConnectionManagerImpl(
|
|||
*/
|
||||
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
|
||||
|
||||
private val HANDSHAKE_TIMEOUT = 30.seconds
|
||||
private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
|
||||
|
||||
/**
|
||||
* Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
|
||||
* 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
|
||||
* nodes.
|
||||
*/
|
||||
private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
|
||||
|
||||
// Shorter window for the retry attempt: if the device genuinely didn't receive the
|
||||
// first want_config_id the retry completes within a few seconds. Waiting another 30s
|
||||
|
|
|
|||
|
|
@ -108,7 +108,10 @@ class SerialRadioTransport(
|
|||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes)"
|
||||
}
|
||||
onDeviceDisconnect(false)
|
||||
// USB unplug / cable error is transient — the transport will reconnect when
|
||||
// the device is replugged or the OS re-enumerates the port. Only an explicit
|
||||
// close() (user disconnects) should signal a permanent disconnect.
|
||||
onDeviceDisconnect(waitForStopped = false, isPermanent = false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ internal class SerialConnectionImpl(
|
|||
|
||||
port.open(usbDeviceConnection)
|
||||
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
|
||||
|
||||
// Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as
|
||||
// present and starts its serial-side Meshtastic protocol. Empirically, omitting these
|
||||
// signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at
|
||||
// Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion.
|
||||
port.dtr = true
|
||||
port.rts = true
|
||||
|
||||
|
|
|
|||
|
|
@ -54,9 +54,7 @@ class UsbRepository(
|
|||
_serialDevices
|
||||
.mapLatest { serialDevices ->
|
||||
val serialProber = usbSerialProberLazy.value
|
||||
buildMap {
|
||||
serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
|
||||
}
|
||||
buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
|
||||
}
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
|
|
@ -83,6 +81,8 @@ class UsbRepository(
|
|||
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
|
||||
}
|
||||
|
||||
private suspend fun refreshStateInternal() =
|
||||
withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
|
||||
private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
|
||||
val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
|
||||
_serialDevices.emit(devices)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,11 @@ class BleRadioTransport(
|
|||
|
||||
@Volatile private var isFullyConnected = false
|
||||
private var connectionJob: Job? = null
|
||||
private val reconnectPolicy = BleReconnectPolicy()
|
||||
|
||||
// Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
|
||||
// own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
|
||||
// toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
|
||||
private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
|
||||
|
||||
private val heartbeatSender =
|
||||
HeartbeatSender(
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ import kotlin.time.Duration.Companion.seconds
|
|||
/**
|
||||
* Encapsulates the BLE reconnection policy with exponential backoff.
|
||||
*
|
||||
* The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or
|
||||
* give up permanently.
|
||||
* The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep).
|
||||
* When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns;
|
||||
* set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely.
|
||||
*
|
||||
* @param maxFailures maximum consecutive failures before giving up permanently
|
||||
* @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
|
||||
* @param failureThreshold after this many consecutive failures, signal a transient disconnect
|
||||
* @param settleDelay delay before each connection attempt to let the BLE stack settle
|
||||
* @param minStableConnection minimum time a connection must stay up to be considered "stable"
|
||||
|
|
@ -148,7 +149,18 @@ class BleReconnectPolicy(
|
|||
companion object {
|
||||
const val DEFAULT_MAX_FAILURES = 10
|
||||
const val DEFAULT_FAILURE_THRESHOLD = 3
|
||||
val DEFAULT_SETTLE_DELAY = 1.seconds
|
||||
|
||||
/**
|
||||
* Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side
|
||||
* GATT session have time to settle.
|
||||
*
|
||||
* Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between
|
||||
* disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
|
||||
* firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
|
||||
* to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
|
||||
* mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
|
||||
*/
|
||||
val DEFAULT_SETTLE_DELAY = 3.seconds
|
||||
val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
|
||||
|
||||
internal val RECONNECT_BASE_DELAY = 5.seconds
|
||||
|
|
|
|||
|
|
@ -37,18 +37,20 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p
|
|||
|
||||
override suspend fun close() {
|
||||
Logger.d { "Closing stream for good" }
|
||||
onDeviceDisconnect(true)
|
||||
onDeviceDisconnect(waitForStopped = true, isPermanent = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the transport callback that our device has gone away, but wait for it to come back.
|
||||
* Signals the transport callback that the device has disconnected and optionally waits for the transport to stop.
|
||||
*
|
||||
* @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
|
||||
* transport callbacks
|
||||
* @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g.
|
||||
* TCP transient disconnect). Defaults to true for serial — subclasses may override with false.
|
||||
* @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O
|
||||
* errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS
|
||||
* re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to
|
||||
* signal a user-initiated terminal disconnect.
|
||||
*/
|
||||
protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) {
|
||||
protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) {
|
||||
callback.onDisconnect(isPermanent = isPermanent)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import dev.mokkery.every
|
|||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
|
|
@ -95,10 +96,10 @@ class BleRadioTransportTest {
|
|||
* [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep
|
||||
* timeout in [MeshConnectionManagerImpl]).
|
||||
*
|
||||
* Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses,
|
||||
* connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay
|
||||
* elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3
|
||||
* settle delay elapses, connectAndAwait throws → onDisconnect called
|
||||
* Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1
|
||||
* settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms —
|
||||
* iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24
|
||||
* 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called
|
||||
*/
|
||||
@Test
|
||||
fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest {
|
||||
|
|
@ -119,10 +120,10 @@ class BleRadioTransportTest {
|
|||
)
|
||||
bleTransport.start()
|
||||
|
||||
// Advance through exactly 3 failure iterations (≈18 001 ms virtual time).
|
||||
// Advance through exactly 3 failure iterations (≈24 001 ms virtual time).
|
||||
// The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended
|
||||
// and advanceTimeBy returns cleanly.
|
||||
advanceTimeBy(18_001L)
|
||||
advanceTimeBy(24_001L)
|
||||
|
||||
verify { service.onDisconnect(any(), any()) }
|
||||
|
||||
|
|
@ -131,16 +132,17 @@ class BleRadioTransportTest {
|
|||
}
|
||||
|
||||
/**
|
||||
* After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and
|
||||
* signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline.
|
||||
* Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected
|
||||
* device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm —
|
||||
* well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must
|
||||
* never call `onDisconnect(isPermanent = true)` from the give-up path.
|
||||
*
|
||||
* Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw +
|
||||
* backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s
|
||||
* settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing
|
||||
* variance.
|
||||
* Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw +
|
||||
* backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s
|
||||
* settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance.
|
||||
*/
|
||||
@Test
|
||||
fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest {
|
||||
fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest {
|
||||
val device = FakeBleDevice(address = address, name = "Test Device")
|
||||
bluetoothRepository.bond(device)
|
||||
|
||||
|
|
@ -158,11 +160,13 @@ class BleRadioTransportTest {
|
|||
)
|
||||
bleTransport.start()
|
||||
|
||||
// Advance enough time for all 10 failures to occur.
|
||||
advanceTimeBy(400_001L)
|
||||
// Run well past where the legacy policy (maxFailures = 10) would have given up.
|
||||
advanceTimeBy(800_001L)
|
||||
|
||||
// Should have been called with isPermanent=true at least once (the final call).
|
||||
verify { service.onDisconnect(isPermanent = true, errorMessage = any()) }
|
||||
// Transient disconnects (isPermanent = false) are expected once the failure threshold is hit;
|
||||
// the policy must NEVER signal a permanent disconnect on its own. Only explicit close()
|
||||
// (verified separately by the service layer) may emit isPermanent = true.
|
||||
verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) }
|
||||
|
||||
bleTransport.close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,11 @@ open class TcpRadioTransport(
|
|||
Logger.d { "[$address] Closing TCP transport" }
|
||||
closing = true
|
||||
transport.stop()
|
||||
callback.onDisconnect(isPermanent = true)
|
||||
// Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the
|
||||
// service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting
|
||||
// it from close() caused a double-disconnect and prevented the auto-reconnect loop from
|
||||
// owning its own lifecycle. The `closing` guard above suppresses the listener's transient
|
||||
// disconnect during teardown.
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,10 @@ private constructor(
|
|||
// Ignore errors during port close
|
||||
}
|
||||
if (isActive) {
|
||||
onDeviceDisconnect(true)
|
||||
// Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as
|
||||
// transient — the user did not explicitly disconnect, and the port may come
|
||||
// back when the device is replugged or the OS re-enumerates it.
|
||||
onDeviceDisconnect(waitForStopped = true, isPermanent = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -169,8 +172,10 @@ private constructor(
|
|||
private const val READ_TIMEOUT_MS = 100
|
||||
|
||||
/**
|
||||
* Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
|
||||
* disconnect to the [callback] and returns the (non-connected) instance.
|
||||
* Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient
|
||||
* disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as
|
||||
* non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the
|
||||
* user grants permission); only an explicit close should signal a permanent disconnect.
|
||||
*/
|
||||
fun open(
|
||||
portName: String,
|
||||
|
|
@ -183,7 +188,7 @@ private constructor(
|
|||
if (!transport.startConnection()) {
|
||||
val errorMessage = diagnoseOpenFailure(portName)
|
||||
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
|
||||
callback.onDisconnect(isPermanent = true, errorMessage = errorMessage)
|
||||
callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,14 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
|
||||
val nodeDb = nodeRepository.nodeDBbyNum
|
||||
|
||||
val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
|
||||
// Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.).
|
||||
// BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we
|
||||
// must restrict the picker to entries whose advertised name matches the
|
||||
// Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN).
|
||||
val bondedBleFlow =
|
||||
bluetoothRepository.state.map { ble ->
|
||||
ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) }
|
||||
}
|
||||
|
||||
val processedTcpFlow =
|
||||
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue