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