diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt index a3b42260d1..af8704d542 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt @@ -17,14 +17,12 @@ package org.meshtastic.app.map.component import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.view.DefaultClusterRenderer @@ -54,15 +52,18 @@ fun NodeClusterMarkers( // If that view is not attached to the hierarchy (which it often isn't during rendering), // it fails to find the Lifecycle and SavedState owners. We propagate them to the root view // so the internal snapshot view can find them when walking up the tree. - // We do this in a SideEffect to ensure it happens before or during composition of children. - SideEffect { + // DisposableEffect runs at composition time (not post-composition like SideEffect), + // ensuring owners are set before the Clustering composable triggers marker rendering. + DisposableEffect(lifecycleOwner, savedStateRegistryOwner) { val root = view.rootView - if (root.findViewTreeLifecycleOwner() == null) { - root.setViewTreeLifecycleOwner(lifecycleOwner) - } - if (root.findViewTreeSavedStateRegistryOwner() == null) { - root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) + root.setViewTreeLifecycleOwner(lifecycleOwner) + root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) + // Also set on the view itself in case the internal renderer walks from a child + if (view !== root) { + view.setViewTreeLifecycleOwner(lifecycleOwner) + view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) } + onDispose {} } Clustering( diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NumberFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NumberFormatter.kt index e76e7c029b..0a65a7dc03 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NumberFormatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NumberFormatter.kt @@ -23,13 +23,17 @@ import kotlin.math.roundToLong object NumberFormatter { /** Formats a double value with the specified number of decimal places. */ fun format(value: Double, decimalPlaces: Int): String { + if (value.isNaN() || value.isInfinite()) return "—" val factor = 10.0.pow(decimalPlaces) val rounded = (value * factor).roundToLong() return formatFixedPoint(rounded, decimalPlaces) } /** Formats a float value with the specified number of decimal places. */ - fun format(value: Float, decimalPlaces: Int): String = format(value.toDouble(), decimalPlaces) + fun format(value: Float, decimalPlaces: Int): String { + if (value.isNaN() || value.isInfinite()) return "—" + return format(value.toDouble(), decimalPlaces) + } private fun formatFixedPoint(scaledValue: Long, decimalPlaces: Int): String { if (decimalPlaces == 0) return scaledValue.toString() diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/NumberFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/NumberFormatterTest.kt index 041ed91fa8..29c61fde05 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/NumberFormatterTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/NumberFormatterTest.kt @@ -35,4 +35,17 @@ class NumberFormatterTest { assertEquals("1", NumberFormatter.format(1.23, 0)) assertEquals("-1", NumberFormatter.format(-1.23, 0)) } + + @Test + fun testFormatNaN() { + assertEquals("—", NumberFormatter.format(Double.NaN, 2)) + assertEquals("—", NumberFormatter.format(Float.NaN, 1)) + } + + @Test + fun testFormatInfinity() { + assertEquals("—", NumberFormatter.format(Double.POSITIVE_INFINITY, 2)) + assertEquals("—", NumberFormatter.format(Double.NEGATIVE_INFINITY, 2)) + assertEquals("—", NumberFormatter.format(Float.POSITIVE_INFINITY, 1)) + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 6504faf80f..60f2310c39 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -51,7 +51,13 @@ class StoreForwardPacketHandlerImpl( override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { val payload = packet.decoded?.payload ?: return - val u = StoreAndForward.ADAPTER.decode(payload) + val u = + try { + StoreAndForward.ADAPTER.decode(payload) + } catch (e: IOException) { + Logger.e(e) { "Failed to parse StoreAndForward packet" } + return + } handleReceivedStoreAndForward(dataPacket, u, myNodeNum) } diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 9bf237733d..702f53eebe 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -338,4 +338,18 @@ class StoreForwardPacketHandlerImplTest { verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } } + + // ---------- Legacy S&F: malformed proto ---------- + + @Test + fun `handleStoreAndForward with malformed payload does not crash`() = testScope.runTest { + val malformedPayload = ByteString.of(0xFF.toByte(), 0xFE.toByte(), 0x07, 0x0E) + val packet = + MeshPacket(from = 999, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = malformedPayload)) + val dataPacket = makeDataPacket(999) + + // Should not throw — the handler catches the IOException from proto decoding + handler.handleStoreAndForward(packet, dataPacket, myNodeNum) + advanceUntilIdle() + } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt index c9f3a904f7..e89abbcb35 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt @@ -86,14 +86,23 @@ internal class SerialConnectionImpl( } port.open(usbDeviceConnection) - port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + try { + port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) - // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as - // present and starts its serial-side Meshtastic protocol. Empirically, omitting these - // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at - // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion. - port.dtr = true - port.rts = true + // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as + // present and starts its serial-side Meshtastic protocol. Empirically, omitting these + // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at + // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion. + port.dtr = true + port.rts = true + } catch (e: java.io.IOException) { + Logger.w(e) { "USB control transfer failed during port setup — device may have disconnected" } + closed.set(true) + ignoreException(silent = true) { port.close() } + closedLatch.countDown() + listener.onDisconnected(e) + return + } Logger.d { "Starting serial reader thread" } val io =