feat: implement XModem file transfers and enhance BLE connection robustness (#4959)

This commit is contained in:
James Rich
2026-03-30 22:49:31 -05:00
committed by GitHub
parent ae4465d7c8
commit c75c9b34d6
43 changed files with 1100 additions and 120 deletions

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:KableBleConnection.kt$KableBleConnection$512</ID>
<ID>MagicNumber:KablePlatformSetup.kt$3</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -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 {

View File

@@ -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 }
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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() }
}
}
}

View File

@@ -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) {

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,11 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues/>
<CurrentIssues>
<ID>MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$0x8000</ID>
<ID>MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$0xFF</ID>
<ID>MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$0xFFFF</ID>
<ID>MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$8</ID>
<ID>TooManyFunctions:RadioConfigRepositoryImpl.kt$RadioConfigRepositoryImpl : RadioConfigRepository</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -16,7 +16,12 @@
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
@@ -26,7 +31,13 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.duplicated_public_key_title
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.key_verification_final_title
import org.meshtastic.core.resources.key_verification_request_title
import org.meshtastic.core.resources.key_verification_title
import org.meshtastic.core.resources.low_entropy_key_title
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@@ -38,6 +49,11 @@ class FromRadioPacketHandlerImpl(
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
) : FromRadioPacketHandler {
// Application-scoped coroutine context for suspend work (e.g. getStringSuspend).
// This @Single lives for the entire app lifetime, so the SupervisorJob is never cancelled.
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
@Suppress("CyclomaticComplexMethod")
override fun handleFromRadio(proto: FromRadio) {
val myInfo = proto.my_info
@@ -50,9 +66,15 @@ class FromRadioPacketHandlerImpl(
val moduleConfig = proto.moduleConfig
val channel = proto.channel
val clientNotification = proto.clientNotification
val deviceUIConfig = proto.deviceuiConfig
val fileInfo = proto.fileInfo
val xmodemPacket = proto.xmodemPacket
when {
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
// deviceuiConfig arrives immediately after my_info (STATE_SEND_UIDATA). It carries
// the device's display, theme, node-filter, and other UI preferences.
deviceUIConfig != null -> router.value.configHandler.handleDeviceUIConfig(deviceUIConfig)
metadata != null -> router.value.configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
router.value.configFlowManager.handleNodeInfo(nodeInfo)
@@ -64,17 +86,48 @@ class FromRadioPacketHandlerImpl(
config != null -> router.value.configHandler.handleDeviceConfig(config)
moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig)
channel != null -> router.value.configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
notificationManager.dispatch(
Notification(
title = getString(Res.string.client_notification),
message = clientNotification.message,
category = Notification.Category.Alert,
),
)
packetHandler.removeResponse(0, complete = false)
}
fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo)
xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket)
clientNotification != null -> handleClientNotification(clientNotification)
}
}
private fun handleClientNotification(cn: ClientNotification) {
serviceRepository.setClientNotification(cn)
scope.handledLaunch {
val inform = cn.key_verification_number_inform
val request = cn.key_verification_number_request
val verificationFinal = cn.key_verification_final
val (title, type) =
when {
inform != null -> {
Logger.i { "Key verification inform from ${inform.remote_longname}" }
Pair(getStringSuspend(Res.string.key_verification_title), Notification.Type.Info)
}
request != null -> {
Logger.i { "Key verification request from ${request.remote_longname}" }
Pair(getStringSuspend(Res.string.key_verification_request_title), Notification.Type.Info)
}
verificationFinal != null -> {
Logger.i { "Key verification final from ${verificationFinal.remote_longname}" }
Pair(getStringSuspend(Res.string.key_verification_final_title), Notification.Type.Info)
}
cn.duplicated_public_key != null -> {
Logger.w { "Duplicated public key notification received" }
Pair(getStringSuspend(Res.string.duplicated_public_key_title), Notification.Type.Warning)
}
cn.low_entropy_key != null -> {
Logger.w { "Low entropy key notification received" }
Pair(getStringSuspend(Res.string.low_entropy_key_title), Notification.Type.Warning)
}
else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info)
}
notificationManager.dispatch(
Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert),
)
packetHandler.removeResponse(0, complete = false)
}
}
}

View File

@@ -37,6 +37,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
@@ -152,6 +153,8 @@ class MeshConfigFlowManagerImpl(
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearLocalModuleConfig()
radioConfigRepository.clearDeviceUIConfig()
radioConfigRepository.clearFileManifest()
}
}
@@ -165,6 +168,11 @@ class MeshConfigFlowManagerImpl(
newNodes.add(info)
}
override fun handleFileInfo(info: FileInfo) {
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
}
override fun triggerWantConfig() {
connectionManager.value.startConfigOnly()
}

View File

@@ -31,6 +31,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
@@ -82,4 +83,8 @@ class MeshConfigHandlerImpl(
serviceRepository.setConnectionProgress("Channels (${index + 1})")
}
}
override fun handleDeviceUIConfig(config: DeviceUIConfig) {
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
}
}

View File

@@ -184,12 +184,15 @@ class MeshConnectionManagerImpl(
handshakeTimeout = scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.w { "Handshake stall detected! Retrying Stage $stage." }
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
// the stall is on our side, the retry will be dropped and the reconnect below
// will trigger instead — which is the right recovery in that case.
Logger.w { "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled." }
action()
// Recursive timeout for one more try
delay(HANDSHAKE_TIMEOUT)
delay(HANDSHAKE_RETRY_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Resetting connection." }
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
onConnectionChanged(ConnectionState.Disconnected)
}
}
@@ -267,16 +270,24 @@ class MeshConnectionManagerImpl(
}
}
}
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
override fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set device time now that the full node picture is ready. Sending this during Stage 1
// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
// Proactively seed the session passkey. The firmware embeds session_passkey in every
// admin *response* (wantResponse=true), but set_time_only has no response. A get_owner
// request is the lightest way to trigger a response and populate the passkey cache so
// that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
commandSender.sendAdmin(myNodeNum, wantResponse = true) { AdminMessage(get_owner_request = true) }
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
@@ -289,7 +300,6 @@ class MeshConnectionManagerImpl(
reportConnection()
val myNodeNum = nodeManager.myNodeNum ?: 0
// Request history
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
@@ -329,6 +339,11 @@ class MeshConnectionManagerImpl(
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 30.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
// before reconnecting just delays recovery unnecessarily.
private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
private const val EVENT_NUM_NODES = "num_nodes"

View File

@@ -258,7 +258,10 @@ class MeshDataHandlerImpl(
private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
u.session_passkey.let { commandSender.setSessionPasskey(it) }
// Guard against clearing a valid passkey: firmware always embeds the key in every
// admin response, but a missing (default-empty) field must not reset the stored value.
val incomingPasskey = u.session_passkey
if (incomingPasskey.size > 0) commandSender.setSessionPasskey(incomingPasskey)
val fromNum = packet.from
u.get_module_config_response?.let {

View File

@@ -26,6 +26,7 @@ import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.XModemManager
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
@Suppress("LongParameterList")
@@ -38,6 +39,7 @@ class MeshRouterImpl(
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
private val mqttManagerLazy: Lazy<MqttManager>,
private val actionHandlerLazy: Lazy<MeshActionHandler>,
private val xmodemManagerLazy: Lazy<XModemManager>,
) : MeshRouter {
override val dataHandler: MeshDataHandler
get() = dataHandlerLazy.value
@@ -60,6 +62,9 @@ class MeshRouterImpl(
override val actionHandler: MeshActionHandler
get() = actionHandlerLazy.value
override val xmodemManager: XModemManager
get() = xmodemManagerLazy.value
override fun start(scope: CoroutineScope) {
dataHandler.start(scope)
configHandler.start(scope)

View File

@@ -0,0 +1,161 @@
/*
* 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.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.XModemFile
import org.meshtastic.core.repository.XModemManager
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.XModem
import kotlin.concurrent.Volatile
/**
* XModem-CRC receiver state machine.
*
* Protocol summary (device = sender, Android = receiver):
* - SOH / STX → data block with seq, CRC-CCITT-16, payload; reply ACK or NAK
* - EOT → end of transfer; reply ACK, emit assembled file
* - CAN → sender cancelled; reset state
*
* CRC algorithm: CRC-CCITT (poly 0x1021, init 0x0000), same as the Meshtastic firmware.
*/
@Single
class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManager {
private val _fileTransferFlow =
MutableSharedFlow<XModemFile>(
replay = 0,
extraBufferCapacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val fileTransferFlow = _fileTransferFlow.asSharedFlow()
// --- mutable state ---
// Thread-safety contract: [handleIncomingXModem] is called sequentially from
// [FromRadioPacketHandlerImpl.handleFromRadio] on a single IO coroutine. The
// [setTransferName] and [cancel] calls originate from UI/ViewModel coroutines
// and are guarded by @Volatile for visibility. Concurrent block processing is
// not possible because the firmware sends one XModem packet at a time and waits
// for ACK/NAK before sending the next.
@Volatile private var transferName = ""
@Volatile private var expectedSeq = INITIAL_SEQ
private val blocks = mutableListOf<ByteArray>()
override fun setTransferName(name: String) {
transferName = name
}
override fun handleIncomingXModem(packet: XModem) {
when (packet.control) {
XModem.Control.SOH,
XModem.Control.STX,
-> handleDataBlock(packet)
XModem.Control.EOT -> handleEot()
XModem.Control.CAN -> {
Logger.w { "XModem: CAN received — transfer cancelled" }
reset()
}
else -> Logger.w { "XModem: unexpected control byte ${packet.control}, ignoring" }
}
}
private fun handleDataBlock(packet: XModem) {
val seq = packet.seq and 0xFF
val data = packet.buffer.toByteArray()
if (!validateCrc(data, packet.crc16)) {
Logger.w { "XModem: CRC error on block $seq (expected seq=$expectedSeq) — NAK" }
sendControl(XModem.Control.NAK)
return
}
when (seq) {
expectedSeq -> {
blocks.add(data)
expectedSeq = (expectedSeq % MAX_SEQ) + 1
Logger.d { "XModem: block $seq OK, total=${blocks.size} blocks" }
sendControl(XModem.Control.ACK)
}
// Duplicate: sender did not receive our previous ACK; re-ACK without buffering again.
(expectedSeq - 1 + MAX_SEQ_PLUS_ONE) % MAX_SEQ_PLUS_ONE -> {
Logger.d { "XModem: duplicate block $seq — re-ACK" }
sendControl(XModem.Control.ACK)
}
else -> {
Logger.w { "XModem: unexpected seq $seq (expected $expectedSeq) — NAK" }
sendControl(XModem.Control.NAK)
}
}
}
private fun handleEot() {
Logger.i { "XModem: EOT — transfer complete (${blocks.size} blocks, name='$transferName')" }
sendControl(XModem.Control.ACK)
val raw = blocks.fold(ByteArray(0)) { acc, block -> acc + block }
// Strip trailing CTRL-Z padding that XModem senders add to fill the last block.
var end = raw.size
while (end > 0 && raw[end - 1] == CTRLZ) end--
val trimmed = if (end == raw.size) raw else raw.copyOf(end)
_fileTransferFlow.tryEmit(XModemFile(name = transferName, data = trimmed))
reset()
}
override fun cancel() {
Logger.i { "XModem: cancelling transfer" }
sendControl(XModem.Control.CAN)
reset()
}
private fun sendControl(control: XModem.Control) {
packetHandler.sendToRadio(ToRadio(xmodemPacket = XModem(control = control)))
}
private fun reset() {
expectedSeq = INITIAL_SEQ
blocks.clear()
transferName = ""
}
// CRC-CCITT: polynomial 0x1021, initial value 0x0000 (XModem variant)
private fun validateCrc(data: ByteArray, expectedCrc: Int): Boolean =
calculateCrc16(data) == (expectedCrc and 0xFFFF)
private fun calculateCrc16(data: ByteArray): Int {
var crc = 0
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
repeat(BITS_PER_BYTE) { crc = if (crc and 0x8000 != 0) (crc shl 1) xor CRC_POLY else crc shl 1 }
}
return crc and 0xFFFF
}
companion object {
private const val INITIAL_SEQ = 1
private const val MAX_SEQ = 255
private const val MAX_SEQ_PLUS_ONE = 256
private const val CTRLZ = 0x1A.toByte()
private const val CRC_POLY = 0x1021
private const val BITS_PER_BYTE = 8
}
}

View File

@@ -17,6 +17,8 @@
package org.meshtastic.core.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import org.koin.core.annotation.Single
import org.meshtastic.core.datastore.ChannelSetDataSource
@@ -30,6 +32,8 @@ import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
@@ -103,6 +107,30 @@ open class RadioConfigRepositoryImpl(
moduleConfigDataSource.setLocalModuleConfig(config)
}
// DeviceUIConfig is session-scoped data received fresh in every handshake — no persistence needed.
private val _deviceUIConfigFlow = MutableStateFlow<DeviceUIConfig?>(null)
override val deviceUIConfigFlow: Flow<DeviceUIConfig?> = _deviceUIConfigFlow.asStateFlow()
override suspend fun setDeviceUIConfig(config: DeviceUIConfig) {
_deviceUIConfigFlow.value = config
}
override suspend fun clearDeviceUIConfig() {
_deviceUIConfigFlow.value = null
}
// FileInfo manifest is session-scoped: accumulated during STATE_SEND_FILEMANIFEST, cleared on each new handshake.
private val _fileManifestFlow = MutableStateFlow<List<FileInfo>>(emptyList())
override val fileManifestFlow: Flow<List<FileInfo>> = _fileManifestFlow.asStateFlow()
override suspend fun addFileInfo(info: FileInfo) {
_fileManifestFlow.value += info
}
override suspend fun clearFileManifest() {
_fileManifestFlow.value = emptyList()
}
/** Flow representing the combined [DeviceProfile] protobuf. */
override val deviceProfileFlow: Flow<DeviceProfile> =
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {

View File

@@ -0,0 +1,144 @@
/*
* 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.data.manager
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.XModem
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class XModemManagerImplTest {
private lateinit var packetHandler: PacketHandler
private lateinit var xmodemManager: XModemManagerImpl
@BeforeTest
fun setup() {
packetHandler = mock<PacketHandler> { every { sendToRadio(any<ToRadio>()) } returns Unit }
xmodemManager = XModemManagerImpl(packetHandler)
}
private fun calculateExpectedCrc(data: ByteArray): Int {
var crc = 0
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
repeat(8) { crc = if (crc and 0x8000 != 0) (crc shl 1) xor 0x1021 else crc shl 1 }
}
return crc and 0xFFFF
}
@Test
fun `successful transfer emits file and ACKs blocks`() = runTest {
val payload1 = "Hello, ".encodeToByteArray()
val payload2 = "Meshtastic!".encodeToByteArray()
xmodemManager.setTransferName("test.txt")
xmodemManager.fileTransferFlow.test {
// Send Block 1
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 1,
crc16 = calculateExpectedCrc(payload1),
buffer = payload1.toByteString(),
),
)
// Send Block 2
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 2,
crc16 = calculateExpectedCrc(payload2),
buffer = payload2.toByteString(),
),
)
// EOT
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.EOT))
val file = awaitItem()
assertEquals("test.txt", file.name)
assertEquals("Hello, Meshtastic!", file.data.decodeToString())
verify(exactly(3)) { packetHandler.sendToRadio(any<ToRadio>()) }
}
}
@Test
fun `ignores bad CRC and replies NAK`() = runTest {
val payload1 = "Bad CRC payload".encodeToByteArray()
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 1,
crc16 = 0xBAD, // intentionally bad
buffer = payload1.toByteString(),
),
)
verify(exactly(1)) { packetHandler.sendToRadio(any<ToRadio>()) }
}
@Test
fun `handles CAN and resets state`() = runTest {
xmodemManager.setTransferName("bad.txt")
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.CAN))
// No control sent back for CAN by the device, just resets.
// If we cancel locally, we send CAN. Wait, the test is for receiving CAN.
// So nothing should be sent, but state should reset.
// Let's verify no ACK/NAK sent when receiving CAN.
verify(exactly(0)) { packetHandler.sendToRadio(any<ToRadio>()) }
}
@Test
fun `removes CTRLZ padding from end of file`() = runTest {
val payload = byteArrayOf(0x48, 0x69, 0x1A, 0x1A) // "Hi" + CTRL-Z padding
xmodemManager.setTransferName("padded.txt")
xmodemManager.fileTransferFlow.test {
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 1,
crc16 = calculateExpectedCrc(payload),
buffer = payload.toByteString(),
),
)
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.EOT))
val file = awaitItem()
val expected = byteArrayOf(0x48, 0x69) // "Hi"
assertTrue(expected.contentEquals(file.data))
}
}
}

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:BleRadioInterface.kt$4</ID>
<ID>MagicNumber:BleRadioInterface.kt$BleRadioInterface$2_000L</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -22,6 +22,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
@@ -35,6 +36,8 @@ import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
@@ -49,13 +52,37 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import kotlin.concurrent.Volatile
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private const val RECONNECT_FAILURE_THRESHOLD = 3
private const val RECONNECT_BASE_DELAY_MS = 5_000L
private const val RECONNECT_MAX_DELAY_MS = 60_000L
/**
* Returns the reconnect backoff delay in milliseconds for a given consecutive failure count.
*
* Backoff schedule: 1 failure → 5 s 2 failures → 10 s 3 failures → 20 s 4 failures → 40 s 5+ failures → 60 s (capped)
*/
internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long {
if (consecutiveFailures <= 0) return RECONNECT_BASE_DELAY_MS
return minOf(RECONNECT_BASE_DELAY_MS * (1L shl (consecutiveFailures - 1).coerceAtMost(4)), RECONNECT_MAX_DELAY_MS)
}
// Milliseconds to wait after launching characteristic observations before triggering the
// Meshtastic handshake. Both fromRadio and logRadio observation flows write the CCCD
// asynchronously via Kable's GATT queue. Without this settle window the want_config_id
// burst from the radio can arrive before notifications are enabled, causing the first
// handshake attempt to look like a stall.
private const val CCCD_SETTLE_MS = 50L
private val SCAN_TIMEOUT = 5.seconds
/**
@@ -113,6 +140,9 @@ class BleRadioInterface(
private var connectionJob: Job? = null
private var consecutiveFailures = 0
@OptIn(ExperimentalAtomicApi::class)
private val heartbeatNonce = AtomicInt(0)
init {
connect()
}
@@ -122,7 +152,7 @@ class BleRadioInterface(
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findDevice(): BleDevice {
bluetoothRepository.state.value.bondedDevices
.firstOrNull { it.address == address }
.firstOrNull { it.address.equals(address, ignoreCase = true) }
?.let {
return it
}
@@ -132,9 +162,9 @@ class BleRadioInterface(
repeat(SCAN_RETRY_COUNT) { attempt ->
try {
val d =
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
withTimeoutOrNull(SCAN_TIMEOUT) {
scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
it.address == address
it.address.equals(address, ignoreCase = true)
}
}
if (d != null) return d
@@ -150,6 +180,7 @@ class BleRadioInterface(
throw RadioNotConnectedException("Device not found at address $address")
}
@Suppress("LongMethod")
private fun connect() {
connectionJob = connectionScope.launch {
while (isActive) {
@@ -158,22 +189,39 @@ class BleRadioInterface(
// to settle before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
kotlinx.coroutines.delay(connectDelayMs)
delay(connectDelayMs)
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
val device = findDevice()
// Ensure the device is bonded before connecting. On Android, the
// firmware may require an encrypted link (pairing mode != NO_PIN).
// Without an explicit bond the GATT connection will fail with
// insufficient-authentication (status 5) or the dreaded status 133.
// On Desktop/JVM this is a no-op since the OS handles pairing during
// the GATT connection when the peripheral requires it.
if (!bluetoothRepository.isBonded(address)) {
Logger.i { "[$address] Device not bonded, initiating bonding..." }
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(device)
Logger.i { "[$address] Bonding successful" }
} catch (e: Exception) {
Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
}
}
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with NotConnectedException
// if the previous peripheral wasn't fully cleaned up by the OS. A quick retry resolves it.
// Kable on Android occasionally fails the first connection attempt with
// NotConnectedException if the previous peripheral wasn't fully cleaned
// up by the OS. A quick retry resolves it.
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
@Suppress("MagicNumber")
val retryDelayMs = 1500L
kotlinx.coroutines.delay(retryDelayMs)
delay(1500L)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
@@ -218,15 +266,19 @@ class BleRadioInterface(
"(consecutive failures: $consecutiveFailures)"
}
// After repeated failures, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. handleFailure covers permanent-error cases.
if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
// At the failure threshold, signal DeviceSleep so MeshConnectionManagerImpl can
// start its sleep timeout. Use == (not >=) to fire exactly once; repeated
// onDisconnect signals would reset upstream state machines unnecessarily.
if (consecutiveFailures == RECONNECT_FAILURE_THRESHOLD) {
handleFailure(e)
}
// Wait before retrying to prevent hot loops
@Suppress("MagicNumber")
kotlinx.coroutines.delay(5000L)
// Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
// Reduces BLE stack pressure and battery drain when the device is genuinely
// out of range, while still recovering quickly from transient drops.
val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
delay(backoffMs)
}
}
}
@@ -297,6 +349,12 @@ class BleRadioInterface(
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// Give Kable's async CCCD writes time to complete before triggering the
// Meshtastic handshake. The fromRadio/logRadio observation flows register
// notifications through the GATT queue asynchronously. Without this settle
// window, the want_config_id burst arrives before notifications are enabled.
delay(CCCD_SETTLE_MS)
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
@@ -305,8 +363,15 @@ class BleRadioInterface(
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
bleConnection.disconnect()
handleFailure(e)
// Ensure the peripheral is disconnected so the outer reconnect loop sees a clean
// Disconnected state. Do NOT call handleFailure here — the reconnect loop tracks
// consecutive failures and calls handleFailure after RECONNECT_FAILURE_THRESHOLD,
// preventing premature onDisconnect signals to the service on transient errors.
try {
bleConnection.disconnect()
} catch (ignored: Exception) {
Logger.w(ignored) { "[$address] disconnect() failed after profile error" }
}
}
}
@@ -347,34 +412,57 @@ class BleRadioInterface(
}
}
@OptIn(ExperimentalAtomicApi::class)
override fun keepAlive() {
Logger.d { "[$address] BLE keepAlive" }
// Send a ToRadio heartbeat so the firmware resets its power-saving idle timer.
// The firmware only resets the timer on writes to the TORADIO characteristic; a
// BLE-level GATT keepalive is invisible to it. Without this the device may enter
// light-sleep and drop the BLE connection after ~60 s of application inactivity.
//
// Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the
// firmware's per-connection duplicate-write filter from silently dropping it.
val nonce = heartbeatNonce.fetchAndAdd(1)
Logger.d { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode())
}
/** Closes the connection to the device. */
override fun close() {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
Logger.i {
"[$address] Disconnecting. " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
// Cancel the connection scope FIRST to break the while(isActive) reconnect loop,
// then perform async cleanup on the parent serviceScope.
// Cancel the connection scope to break the while(isActive) reconnect loop.
connectionScope.cancel("close() called")
// GATT cleanup must run regardless of serviceScope lifecycle. SharedRadioInterfaceService
// cancels serviceScope immediately after calling close(), so launching on serviceScope is
// not reliable — the coroutine may never start. We use withContext(NonCancellable) inside
// a serviceScope.launch to guarantee cleanup completes even if the scope is cancelled
// mid-flight, preventing leaked BluetoothGatt objects (GATT 133 errors).
// onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly.
serviceScope.launch {
try {
bleConnection.disconnect()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
withContext(NonCancellable) {
// Send ToRadio.disconnect before dropping the BLE link. The firmware calls its
// own close() immediately on receipt, resetting the PhoneAPI state machine
// (config nonce, packet queue, observers) without waiting for the 6-second BLE
// supervision timeout. Best-effort: if the write fails we still disconnect below.
val currentService = radioService
if (currentService != null) {
try {
withTimeoutOrNull(2_000L) { currentService.sendToRadio(ToRadio(disconnect = true).encode()) }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to send disconnect signal" }
}
}
try {
bleConnection.disconnect()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in close()" }
}
}
service.onDisconnect(true)
}
}

View File

@@ -17,10 +17,14 @@
package org.meshtastic.core.network.radio
import dev.mokkery.MockMode
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.testing.FakeBleConnection
import org.meshtastic.core.testing.FakeBleConnectionFactory
@@ -82,4 +86,42 @@ class BleRadioInterfaceTest {
)
assertEquals(address, bleInterface.address)
}
/**
* After [RECONNECT_FAILURE_THRESHOLD] consecutive connection failures, [RadioInterfaceService.onDisconnect] must be
* called so the higher layers can react (e.g. start the device-sleep timeout in [MeshConnectionManagerImpl]).
*
* Virtual-time breakdown (RECONNECT_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
*/
@Test
fun `onDisconnect is called after RECONNECT_FAILURE_THRESHOLD consecutive failures`() = runTest {
val device = FakeBleDevice(address = address, name = "Test Device")
bluetoothRepository.bond(device) // skip BLE scan — device is already bonded
// Make every connectAndAwait call throw so each iteration counts as one failure.
connection.connectException = RadioNotConnectedException("simulated failure")
val bleInterface =
BleRadioInterface(
serviceScope = this,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
// Advance through exactly 3 failure iterations (≈18 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)
verify { service.onDisconnect(any(), any()) }
// Cancel the reconnect loop so runTest can complete.
bleInterface.close()
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.network.radio
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Tests the exponential backoff schedule used by [BleRadioInterface] when consecutive connection attempts fail. The
* schedule is: failure #1 → 5 s failure #2 → 10 s failure #3 → 20 s failure #4 → 40 s failure #5+ → 60 s (capped)
*/
class ReconnectBackoffTest {
@Test
fun `zero failures yields base delay`() {
assertEquals(5_000L, computeReconnectBackoffMs(0))
}
@Test
fun `first failure yields 5s`() {
assertEquals(5_000L, computeReconnectBackoffMs(1))
}
@Test
fun `second failure yields 10s`() {
assertEquals(10_000L, computeReconnectBackoffMs(2))
}
@Test
fun `third failure yields 20s`() {
assertEquals(20_000L, computeReconnectBackoffMs(3))
}
@Test
fun `fourth failure yields 40s`() {
assertEquals(40_000L, computeReconnectBackoffMs(4))
}
@Test
fun `fifth failure is capped at 60s`() {
assertEquals(60_000L, computeReconnectBackoffMs(5))
}
@Test
fun `large failure count stays capped at 60s`() {
assertEquals(60_000L, computeReconnectBackoffMs(100))
}
@Test
fun `backoff is strictly increasing up to the cap`() {
val values = (1..5).map { computeReconnectBackoffMs(it) }
for (i in 0 until values.size - 1) {
assertTrue(
values[i] < values[i + 1],
"Expected backoff[${i + 1}] (${values[i]}) < backoff[${i + 2}] (${values[i + 1]})",
)
}
}
}

View File

@@ -18,6 +18,7 @@ package org.meshtastic.core.repository
import kotlinx.coroutines.CoroutineScope
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
@@ -35,6 +36,14 @@ interface MeshConfigFlowManager {
/** Handles received node information. */
fun handleNodeInfo(info: NodeInfo)
/**
* Handles a [FileInfo] packet received during STATE_SEND_FILEMANIFEST.
*
* Each packet describes one file available on the device. Accumulated into [RadioConfigRepository.fileManifestFlow]
* and cleared at the start of each new handshake.
*/
fun handleFileInfo(info: FileInfo)
/** Returns the number of nodes received in the current stage. */
val newNodeCount: Int

View File

@@ -20,6 +20,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
@@ -43,4 +44,10 @@ interface MeshConfigHandler {
/** Handles a received channel configuration. */
fun handleChannel(channel: Channel)
/**
* Handles the [DeviceUIConfig] received during the config handshake (STATE_SEND_UIDATA). This arrives as the 2nd
* packet in every handshake, immediately after my_info.
*/
fun handleDeviceUIConfig(config: DeviceUIConfig)
}

View File

@@ -43,4 +43,7 @@ interface MeshRouter {
/** Access to the action handler. */
val actionHandler: MeshActionHandler
/** Access to the XModem file-transfer manager. */
val xmodemManager: XModemManager
}

View File

@@ -22,10 +22,13 @@ import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
@Suppress("TooManyFunctions")
interface RadioConfigRepository {
/** Flow representing the [ChannelSet] data store. */
val channelSetFlow: Flow<ChannelSet>
@@ -59,4 +62,30 @@ interface RadioConfigRepository {
/** Flow representing the combined [DeviceProfile] protobuf. */
val deviceProfileFlow: Flow<DeviceProfile>
/**
* Flow of the device's UI configuration, populated from [DeviceUIConfig] during the config handshake
* (STATE_SEND_UIDATA — 2nd packet in every handshake). Null until the first handshake completes or after
* [clearDeviceUIConfig] is called.
*/
val deviceUIConfigFlow: Flow<DeviceUIConfig?>
/** Stores the [DeviceUIConfig] received from the device. */
suspend fun setDeviceUIConfig(config: DeviceUIConfig)
/** Clears the stored [DeviceUIConfig]; called at the start of each new handshake. */
suspend fun clearDeviceUIConfig()
/**
* Flow of [FileInfo] packets accumulated during STATE_SEND_FILEMANIFEST.
*
* Cleared at the start of each new handshake via [clearFileManifest].
*/
val fileManifestFlow: Flow<List<FileInfo>>
/** Appends a single [FileInfo] entry to [fileManifestFlow]. */
suspend fun addFileInfo(info: FileInfo)
/** Clears the accumulated file manifest; called at the start of each new handshake. */
suspend fun clearFileManifest()
}

View File

@@ -0,0 +1,33 @@
/*
* 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.repository
/** A file received via an XModem transfer from the connected device. */
data class XModemFile(
/** Filename as set via [XModemManager.setTransferName] before the transfer started. */
val name: String,
/** Raw bytes of the received file (trailing CTRLZ padding stripped). */
val data: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is XModemFile) return false
return name == other.name && data.contentEquals(other.data)
}
override fun hashCode(): Int = 31 * name.hashCode() + data.contentHashCode()
}

View File

@@ -0,0 +1,54 @@
/*
* 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.repository
import kotlinx.coroutines.flow.Flow
import org.meshtastic.proto.XModem
/**
* Handles the XModem-CRC receive protocol for file transfers from the connected device.
*
* The device (sender) initiates transfers in response to admin file-read requests. The Android client (receiver)
* acknowledges each 128-byte block and signals end-of-transfer acceptance.
*
* Usage:
* 1. Optionally call [setTransferName] with the filename being requested so the emitted [XModemFile] is labelled
* correctly.
* 2. Route every [FromRadio.xmodemPacket] here via [handleIncomingXModem].
* 3. Collect [fileTransferFlow] to receive completed files.
*/
interface XModemManager {
/**
* Hot flow that emits once per completed transfer. Backpressure is handled by a small buffer; older transfers are
* dropped if the consumer is slow.
*/
val fileTransferFlow: Flow<XModemFile>
/**
* Sets the name to attach to the next completed transfer.
*
* Call this immediately before (or after) sending the admin file-read request to the device so the emitted
* [XModemFile] is labelled with the correct path.
*/
fun setTransferName(name: String)
/** Routes an incoming XModem packet from the device to the receive state machine. */
fun handleIncomingXModem(packet: XModem)
/** Cancels any in-progress transfer and sends a CAN control byte to the device. */
fun cancel()
}

View File

@@ -852,6 +852,11 @@
<string name="show_waypoints">Show Waypoints</string>
<string name="show_precision_circle">Show Precision Circles</string>
<string name="client_notification">Client Notification</string>
<string name="key_verification_title">Key Verification</string>
<string name="key_verification_request_title">Key Verification Request</string>
<string name="key_verification_final_title">Key Verification Complete</string>
<string name="duplicated_public_key_title">Duplicate Public Key Detected</string>
<string name="low_entropy_key_title">Weak Encryption Key Detected</string>
<string name="compromised_keys">Compromised keys detected, select OK to regenerate.</string>
<string name="regenerate_private_key">Regenerate Private Key</string>
<string name="regenerate_keys_confirmation">Are you sure you want to regenerate your Private Key?\n\nNodes that may have previously exchanged keys with this node will need to Remove that node and re-exchange keys in order to resume secure communication.</string>
@@ -1296,4 +1301,10 @@
<string name="update_device">Update Device</string>
<string name="note">Note</string>
<string name="firmware_charge_warning">Ensure your device is fully charged before starting a firmware update. Do not disconnect or power off the device during the update process.</string>
<string name="device_storage_ui_title">Device Storage &amp; UI (Read-Only)</string>
<string name="device_theme_language">Theme: %1$s, Language: %2$s</string>
<string name="files_available">Files available (%1$d):</string>
<string name="file_entry">- %1$s (%2$d bytes)</string>
<string name="no_files_manifested">No files manifested.</string>
</resources>

View File

@@ -209,9 +209,10 @@ class SharedRadioInterfaceService(
private fun startInterfaceLocked() {
if (radioIf != null) return
val address =
getBondedDeviceAddress()
?: if (isMockInterface()) transportFactory.toInterfaceAddress(InterfaceId.MOCK, "") else null
// Never autoconnect to the simulated node. The mock transport may be offered in the
// device-picker UI on debug builds, but it must only connect when the user explicitly
// selects it (i.e. its address is stored in radioPrefs).
val address = getBondedDeviceAddress()
if (address == null) {
Logger.w { "No valid address to connect to." }

View File

@@ -94,6 +94,12 @@ class FakeBleConnection :
private val _connectionState = mutableSharedFlow<BleConnectionState>(replay = 1)
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
/** When > 0, the next [failNextN] calls to [connectAndAwait] return [BleConnectionState.Disconnected]. */
var failNextN: Int = 0
/** When non-null, [connectAndAwait] throws this exception instead of connecting. */
var connectException: Exception? = null
override suspend fun connect(device: BleDevice) {
_device.value = device
_deviceFlow.emit(device)
@@ -107,13 +113,13 @@ class FakeBleConnection :
}
}
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState {
connectException?.let { throw it }
if (failNextN > 0) {
failNextN--
return BleConnectionState.Disconnected
}
connect(device)
onRegister()
return BleConnectionState.Connected
}
@@ -154,7 +160,8 @@ class FakeBluetoothRepository :
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotBlank()
override fun isBonded(address: String): Boolean = _state.value.bondedDevices.any { it.address == address }
override fun isBonded(address: String): Boolean =
_state.value.bondedDevices.any { it.address.equals(address, ignoreCase = true) }
override suspend fun bond(device: BleDevice) {
val currentState = _state.value

View File

@@ -35,7 +35,7 @@ import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@KoinViewModel
@KoinViewModel(binds = [ScannerViewModel::class])
@Suppress("LongParameterList", "TooManyFunctions")
class AndroidScannerViewModel(
serviceRepository: ServiceRepository,

View File

@@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
@@ -44,7 +43,6 @@ import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")
open class ScannerViewModel(
protected val serviceRepository: ServiceRepository,
@@ -118,9 +116,14 @@ open class ScannerViewModel(
val bonded = discovered?.bleDevices?.filterIsInstance<DeviceListEntry.Ble>() ?: emptyList()
val bondedAddresses = bonded.map { it.address }.toSet()
// Add scanned devices that aren't already in the bonded list
// Add scanned devices that aren't already in the bonded list.
// These are explicitly marked as unbonded so the UI routes through
// requestBonding() — which on Android triggers createBond() for the
// pairing dialog before connecting.
val unbondedScanned =
scannedMap.values.filter { it.address !in bondedAddresses }.map { DeviceListEntry.Ble(it) }
scannedMap.values
.filter { it.address !in bondedAddresses }
.map { DeviceListEntry.Ble(device = it, bonded = false) }
// Sort by name
(bonded + unbondedScanned).sortedBy { it.name }
@@ -231,8 +234,16 @@ open class ScannerViewModel(
}
}
/** Initiates the bonding process and connects to the device upon success. */
protected open fun requestBonding(entry: DeviceListEntry.Ble) {}
/**
* Initiates the bonding process and connects to the device upon success.
*
* The default implementation connects directly without explicit bonding, which is correct for Desktop/JVM where the
* OS Bluetooth stack handles pairing during the GATT connection. Android overrides this to call `createBond()`
* first.
*/
protected open fun requestBonding(entry: DeviceListEntry.Ble) {
changeDeviceAddress(entry.fullAddress)
}
protected open fun requestPermission(entry: DeviceListEntry.Usb) {}

View File

@@ -39,14 +39,17 @@ sealed class DeviceListEntry(
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
data class Ble(val device: BleDevice, override val node: Node? = null) :
DeviceListEntry(
name = device.name ?: "unnamed-${device.address}",
fullAddress = "x${device.address}",
bonded = device.isBonded,
node = node,
) {
override fun copy(node: Node?): Ble = copy(device = device, node = node)
data class Ble(
val device: BleDevice,
override val bonded: Boolean = device.isBonded,
override val node: Node? = null,
) : DeviceListEntry(
name = device.name ?: "unnamed-${device.address}",
fullAddress = "x${device.address}",
bonded = bonded,
node = node,
) {
override fun copy(node: Node?): Ble = copy(device = device, bonded = bonded, node = node)
}
data class Usb(

View File

@@ -0,0 +1,53 @@
/*
* 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.feature.connections
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
/**
* Desktop/JVM [ScannerViewModel] registration.
*
* On Desktop, the base [ScannerViewModel] is used directly. The default [requestBonding] connects without explicit
* bonding since the OS Bluetooth stack handles pairing during the GATT connection.
*/
@KoinViewModel(binds = [ScannerViewModel::class])
@Suppress("LongParameterList")
class JvmScannerViewModel(
serviceRepository: ServiceRepository,
radioController: RadioController,
radioInterfaceService: RadioInterfaceService,
radioPrefs: RadioPrefs,
recentAddressesDataSource: RecentAddressesDataSource,
getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
bleScanner: org.meshtastic.core.ble.BleScanner? = null,
) : ScannerViewModel(
serviceRepository,
radioController,
radioInterfaceService,
radioPrefs,
recentAddressesDataSource,
getDiscoveredDevicesUseCase,
dispatchers,
bleScanner,
)

View File

@@ -63,7 +63,7 @@ class BleOtaTransportTest {
every { device.name } returns "Test Device"
every { scanner.scan(any(), any()) } returns flowOf(device)
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Disconnected
coEvery { connection.connectAndAwait(any(), any()) } returns BleConnectionState.Disconnected
val result = transport.connect()
assertTrue("Expected failure", result.isFailure)

View File

@@ -71,6 +71,8 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
@@ -92,6 +94,8 @@ data class RadioConfigState(
val ringtone: String = "",
val cannedMessageMessages: String = "",
val deviceConnectionStatus: DeviceConnectionStatus? = null,
val deviceUIConfig: DeviceUIConfig? = null,
val fileManifest: List<FileInfo> = emptyList(),
val responseState: ResponseState<Boolean> = ResponseState.Empty,
val analyticsAvailable: Boolean = true,
val analyticsEnabled: Boolean = false,
@@ -188,6 +192,14 @@ open class RadioConfigViewModel(
}
.launchIn(viewModelScope)
radioConfigRepository.deviceUIConfigFlow
.onEach { uiConfig -> _radioConfigState.update { it.copy(deviceUIConfig = uiConfig) } }
.launchIn(viewModelScope)
radioConfigRepository.fileManifestFlow
.onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } }
.launchIn(viewModelScope)
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
combine(serviceRepository.connectionState, radioConfigState) { connState, _ ->

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.isDebug
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.accept
import org.meshtastic.core.resources.are_you_sure
@@ -66,11 +68,16 @@ import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summar
import org.meshtastic.core.resources.config_device_tzdef_summary
import org.meshtastic.core.resources.config_device_use_phone_tz
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.device_storage_ui_title
import org.meshtastic.core.resources.device_theme_language
import org.meshtastic.core.resources.double_tap_as_button_press
import org.meshtastic.core.resources.file_entry
import org.meshtastic.core.resources.files_available
import org.meshtastic.core.resources.gpio
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.led_heartbeat
import org.meshtastic.core.resources.no_files_manifested
import org.meshtastic.core.resources.nodeinfo_broadcast_interval
import org.meshtastic.core.resources.options
import org.meshtastic.core.resources.rebroadcast_mode
@@ -143,6 +150,7 @@ private val Config.DeviceConfig.RebroadcastMode.description: StringResource
Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
Res.string.rebroadcast_mode_core_portnums_only_desc
else -> Res.string.unrecognized
}
@@ -152,7 +160,7 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig()
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role) }
var selectedRole by rememberSaveable(formState.value.role) { mutableStateOf(formState.value.role) }
val infrastructureRoles =
listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
@@ -309,6 +317,42 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
)
}
}
if ((state.deviceUIConfig != null || state.fileManifest.isNotEmpty()) && isDebug) {
item {
TitledCard(title = stringResource(Res.string.device_storage_ui_title)) {
state.deviceUIConfig?.let { uiConfig ->
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text =
stringResource(
Res.string.device_theme_language,
uiConfig.theme.toString(),
uiConfig.language.toString(),
),
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp))
}
if (state.fileManifest.isNotEmpty()) {
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(Res.string.files_available, state.fileManifest.size),
)
state.fileManifest.forEach { file ->
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(Res.string.file_entry, file.file_name, file.size_bytes),
)
}
} else {
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(Res.string.no_files_manifested),
)
}
}
}
}
}
}

View File

@@ -102,7 +102,7 @@ fun PositionConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
updated
}
val formState = rememberConfigState(initialValue = sanitizedPositionConfig)
var locationInput by rememberSaveable { mutableStateOf(currentPosition) }
var locationInput by rememberSaveable(currentPosition) { mutableStateOf(currentPosition) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(

View File

@@ -111,6 +111,12 @@ class RadioConfigViewModelTest {
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
every { radioConfigRepository.deviceUIConfigFlow } returns MutableStateFlow(null)
every { radioConfigRepository.fileManifestFlow } returns MutableStateFlow(emptyList())
every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(false)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { serviceRepository.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)