diff --git a/core/ble/detekt-baseline.xml b/core/ble/detekt-baseline.xml new file mode 100644 index 000000000..0283be975 --- /dev/null +++ b/core/ble/detekt-baseline.xml @@ -0,0 +1,8 @@ + + + + + MagicNumber:KableBleConnection.kt$KableBleConnection$512 + MagicNumber:KablePlatformSetup.kt$3 + + diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt index 097001d1f..c8d444688 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt @@ -173,9 +173,23 @@ class AndroidBluetoothRepository( } @SuppressLint("MissingPermission") - private fun getBondedAppPeripherals(): List = bluetoothAdapter?.bondedDevices?.map { device -> - deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) } - } ?: emptyList() + private fun getBondedAppPeripherals(): List { + 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 { diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 106d1f8f8..e5033a3c9 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -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 } +} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt index 3855eff05..000b3d030 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -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() diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index f5a325cb9..7ec085834 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -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( + 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 } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt index 42d250c9b..dacfb53bb 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt @@ -30,8 +30,12 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice { private val _state = MutableStateFlow(BleConnectionState.Disconnected) override val state: StateFlow = _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) { diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt index 0b324063c..bea132283 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt @@ -26,15 +26,20 @@ import kotlin.uuid.Uuid class KableBleScanner : BleScanner { override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow { 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() } } } } diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt index 14fcd8310..aa63cc9ba 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt @@ -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(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(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) { diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index 4e9c11cc5..d27ba2225 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -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? diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt index 1ab2d0814..3ad0b6c4d 100644 --- a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt +++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt @@ -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 diff --git a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt index e951cdbd3..33da61ff1 100644 --- a/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt +++ b/core/ble/src/jvmMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt @@ -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 diff --git a/core/data/detekt-baseline.xml b/core/data/detekt-baseline.xml index c373eea43..d744983f5 100644 --- a/core/data/detekt-baseline.xml +++ b/core/data/detekt-baseline.xml @@ -1,5 +1,11 @@ - + + MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$0x8000 + MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$0xFF + MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$0xFFFF + MagicNumber:XModemManagerImpl.kt$XModemManagerImpl$8 + TooManyFunctions:RadioConfigRepositoryImpl.kt$RadioConfigRepositoryImpl : RadioConfigRepository + diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index 4d35a27df..9a84026fa 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -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) } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index 88c376887..2e880bb3b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -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() } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index b8263c253..25a3814fc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -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) } + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index c81469aaa..cb811a96d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -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" diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 7873dc82e..81d2db232 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -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 { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt index d783ae773..aaf109be9 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt @@ -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, private val mqttManagerLazy: Lazy, private val actionHandlerLazy: Lazy, + private val xmodemManagerLazy: Lazy, ) : 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) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt new file mode 100644 index 000000000..6f05c9ccf --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/XModemManagerImpl.kt @@ -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 . + */ +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( + 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() + + 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 + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt index b702d9cab..a4ba80db0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt @@ -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(null) + override val deviceUIConfigFlow: Flow = _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>(emptyList()) + override val fileManifestFlow: Flow> = _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 = combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/XModemManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/XModemManagerImplTest.kt new file mode 100644 index 000000000..830d2dac3 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/XModemManagerImplTest.kt @@ -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 . + */ +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 { every { sendToRadio(any()) } 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()) } + } + } + + @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()) } + } + + @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()) } + } + + @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)) + } + } +} diff --git a/core/network/detekt-baseline.xml b/core/network/detekt-baseline.xml new file mode 100644 index 000000000..9d28ba181 --- /dev/null +++ b/core/network/detekt-baseline.xml @@ -0,0 +1,8 @@ + + + + + MagicNumber:BleRadioInterface.kt$4 + MagicNumber:BleRadioInterface.kt$BleRadioInterface$2_000L + + diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt index a4783a844..68cd0307b 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt @@ -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) } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt index e8bc99588..342a4a766 100644 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceTest.kt @@ -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() + } } diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt new file mode 100644 index 000000000..007b82b45 --- /dev/null +++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt @@ -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 . + */ +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]})", + ) + } + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt index 1f21df1ee..2a92f8909 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt @@ -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 diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt index aae9526f3..3f3887631 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt @@ -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) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt index b4dd60a4d..be2830af9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt @@ -43,4 +43,7 @@ interface MeshRouter { /** Access to the action handler. */ val actionHandler: MeshActionHandler + + /** Access to the XModem file-transfer manager. */ + val xmodemManager: XModemManager } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt index 48053ab80..8dabed66d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt @@ -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 @@ -59,4 +62,30 @@ interface RadioConfigRepository { /** Flow representing the combined [DeviceProfile] protobuf. */ val deviceProfileFlow: Flow + + /** + * 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 + + /** 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> + + /** 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() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/XModemFile.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/XModemFile.kt new file mode 100644 index 000000000..cdac6b935 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/XModemFile.kt @@ -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 . + */ +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() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/XModemManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/XModemManager.kt new file mode 100644 index 000000000..9146affad --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/XModemManager.kt @@ -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 . + */ +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 + + /** + * 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() +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a99956d0f..b4dd96bf8 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -852,6 +852,11 @@ Show Waypoints Show Precision Circles Client Notification + Key Verification + Key Verification Request + Key Verification Complete + Duplicate Public Key Detected + Weak Encryption Key Detected Compromised keys detected, select OK to regenerate. Regenerate Private Key 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. @@ -1296,4 +1301,10 @@ Update Device Note Ensure your device is fully charged before starting a firmware update. Do not disconnect or power off the device during the update process. + + Device Storage & UI (Read-Only) + Theme: %1$s, Language: %2$s + Files available (%1$d): + - %1$s (%2$d bytes) + No files manifested. diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index d08fb5a8a..ac4f2526b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -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." } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt index 50939797a..afe44d8cf 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt @@ -94,6 +94,12 @@ class FakeBleConnection : private val _connectionState = mutableSharedFlow(replay = 1) override val connectionState: SharedFlow = _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 diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt index 979d4892a..3278812fb 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt @@ -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, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 2ad96fd26..e4bb00c6b 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -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() ?: 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) {} diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt index 5a65123f5..abff2c1fb 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt @@ -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( diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt new file mode 100644 index 000000000..1c1597466 --- /dev/null +++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt @@ -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 . + */ +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, +) diff --git a/feature/firmware/src/androidHostTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt b/feature/firmware/src/androidHostTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt index b4ae38af6..c8dda1e29 100644 --- a/feature/firmware/src/androidHostTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt +++ b/feature/firmware/src/androidHostTest/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt @@ -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) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index d3f9808f6..9b385fb58 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -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 = emptyList(), val responseState: ResponseState = 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, _ -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt index 6f469269b..66515e9c7 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigScreen.kt @@ -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), + ) + } + } + } + } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt index 309c7dffb..51d9be609 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PositionConfigScreen.kt @@ -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( diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 864498b2d..007061d47 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -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)