diff --git a/.gitignore b/.gitignore
index 447d8a28e..8447bc7f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,4 +55,3 @@ wireless-install.sh
firebase-debug.log
.agent_plans/
.agent_refs/
-.agent_artifacts/
diff --git a/Gemfile.lock b/Gemfile.lock
index cf6a1b9c0..de497cc4a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
- addressable (2.9.0)
+ addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
- aws-partitions (1.1240.0)
- aws-sdk-core (3.245.0)
+ aws-partitions (1.1213.0)
+ aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
- aws-sdk-kms (1.123.0)
- aws-sdk-core (~> 3, >= 3.244.0)
+ aws-sdk-kms (1.121.0)
+ aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.219.0)
- aws-sdk-core (~> 3, >= 3.244.0)
+ aws-sdk-s3 (1.213.0)
+ aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -29,7 +29,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
- bigdecimal (4.1.2)
+ bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
- faraday-retry (1.0.4)
+ faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
- fastimage (2.4.1)
- fastlane (2.233.0)
+ fastimage (2.4.0)
+ fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
- fastlane-sirp (>= 1.1.0)
+ fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,9 +122,10 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
- fastlane-sirp (1.1.0)
+ fastlane-sirp (1.0.0)
+ sysrandom (~> 1.0)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.99.0)
+ google-apis-androidpublisher_v3 (0.95.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -138,15 +139,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
- google-apis-storage_v1 (0.61.0)
+ google-apis-storage_v1 (0.59.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
- google-cloud-errors (1.6.0)
- google-cloud-storage (1.59.0)
+ google-cloud-errors (1.5.0)
+ google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -168,13 +169,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
- json (2.19.4)
+ json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
- multi_json (1.20.1)
+ multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -184,13 +185,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
- public_suffix (7.0.5)
- rake (13.4.2)
+ public_suffix (7.0.2)
+ rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
- retriable (3.4.1)
+ retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -204,6 +205,7 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
+ sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 628865010..0864e55cd 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -59,7 +59,6 @@ import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
@@ -92,8 +91,6 @@ import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
- private val usbRepository: UsbRepository by inject()
-
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
@@ -169,16 +166,6 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
- override fun onResume() {
- super.onResume()
- // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
- // resumed while a USB device is already attached (e.g. process restart, returning
- // from another app), the manifest-declared attach intent may have already fired
- // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
- // reality without requiring the user to physically replug.
- usbRepository.refreshState()
- }
-
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
@@ -270,11 +257,6 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" }
- // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
- // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
- // never sees this event. Forward it explicitly so the serialDevices StateFlow
- // refreshes and the device shows up in the Connect → Serial tab.
- usbRepository.refreshState()
showSettingsPage()
}
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index 91b8ebce2..2fa797c74 100644
--- a/build-logic/settings.gradle.kts
+++ b/build-logic/settings.gradle.kts
@@ -30,7 +30,7 @@ pluginManagement {
}
plugins {
- id("com.gradle.develocity") version("4.4.1")
+ id("com.gradle.develocity") version("4.4.0")
}
dependencyResolutionManagement {
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
index d273a0b90..6f5180b60 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
@@ -26,9 +26,7 @@ import com.juul.kable.UnmetRequirementException
/**
* Classification of a BLE-layer exception for the transport layer to act on.
*
- * @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
- * Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission
- * grants, transient GATT errors). Reserved for future use.
+ * @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
* @property gattStatus the platform GATT status code when available (Android-specific).
* @property message a human-readable description of the failure.
*/
@@ -52,9 +50,6 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
is GattRequestRejectedException ->
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
is UnmetRequirementException ->
- // Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
- // device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
- // retrying; UI can show a hint based on the message.
- BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
+ BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
else -> null
}
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 022f3548d..a60dc85c5 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
@@ -60,7 +60,6 @@ import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
-import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@@ -212,11 +211,11 @@ class MeshConnectionManagerImpl(
}
}
- private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
+ private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
- delay(timeout)
+ delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
@@ -292,13 +291,13 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
- startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
+ startHandshakeStallGuard(1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
- startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
+ startHandshakeStallGuard(2, action)
action()
}
@@ -405,14 +404,7 @@ class MeshConnectionManagerImpl(
*/
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
- private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
-
- /**
- * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
- * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
- * nodes.
- */
- private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
+ 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
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
index 0f7985276..bc3558800 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
@@ -108,10 +108,7 @@ class SerialRadioTransport(
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
- // USB unplug / cable error is transient — the transport will reconnect when
- // the device is replugged or the OS re-enumerates the port. Only an explicit
- // close() (user disconnects) should signal a permanent disconnect.
- onDeviceDisconnect(waitForStopped = false, isPermanent = false)
+ onDeviceDisconnect(false)
}
},
)
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 d8b14be03..b2ccf6545 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
@@ -87,11 +87,6 @@ internal class SerialConnectionImpl(
port.open(usbDeviceConnection)
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
-
- // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as
- // present and starts its serial-side Meshtastic protocol. Empirically, omitting these
- // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at
- // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion.
port.dtr = true
port.rts = true
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
index c5080ec14..b4773dff3 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
@@ -54,7 +54,9 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
- buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
+ buildMap {
+ serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
+ }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -81,8 +83,6 @@ class UsbRepository(
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
- private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
- val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
- _serialDevices.emit(devices)
- }
+ private suspend fun refreshStateInternal() =
+ withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
index f2ba25804..77114ff55 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -133,11 +133,7 @@ class BleRadioTransport(
@Volatile private var isFullyConnected = false
private var connectionJob: Job? = null
-
- // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
- // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
- // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
- private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
+ private val reconnectPolicy = BleReconnectPolicy()
private val heartbeatSender =
HeartbeatSender(
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
index e4d250796..cef746af0 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
@@ -26,11 +26,10 @@ import kotlin.time.Duration.Companion.seconds
/**
* Encapsulates the BLE reconnection policy with exponential backoff.
*
- * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep).
- * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns;
- * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely.
+ * The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or
+ * give up permanently.
*
- * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
+ * @param maxFailures maximum consecutive failures before giving up permanently
* @param failureThreshold after this many consecutive failures, signal a transient disconnect
* @param settleDelay delay before each connection attempt to let the BLE stack settle
* @param minStableConnection minimum time a connection must stay up to be considered "stable"
@@ -149,18 +148,7 @@ class BleReconnectPolicy(
companion object {
const val DEFAULT_MAX_FAILURES = 10
const val DEFAULT_FAILURE_THRESHOLD = 3
-
- /**
- * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side
- * GATT session have time to settle.
- *
- * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between
- * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
- * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
- * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
- * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
- */
- val DEFAULT_SETTLE_DELAY = 3.seconds
+ val DEFAULT_SETTLE_DELAY = 1.seconds
val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
internal val RECONNECT_BASE_DELAY = 5.seconds
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
index 8c689dbcb..ac912346a 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
@@ -37,20 +37,18 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p
override suspend fun close() {
Logger.d { "Closing stream for good" }
- onDeviceDisconnect(waitForStopped = true, isPermanent = true)
+ onDeviceDisconnect(true)
}
/**
- * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop.
+ * Notify the transport callback that our device has gone away, but wait for it to come back.
*
* @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
* transport callbacks
- * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O
- * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS
- * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to
- * signal a user-initiated terminal disconnect.
+ * @param isPermanent true if the device is definitely gone (e.g. USB unplugged), false if it may come back (e.g.
+ * TCP transient disconnect). Defaults to true for serial — subclasses may override with false.
*/
- protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) {
+ protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) {
callback.onDisconnect(isPermanent = isPermanent)
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
index 840dc214a..f1049f897 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
@@ -22,7 +22,6 @@ import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
-import dev.mokkery.verify.VerifyMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
@@ -96,10 +95,10 @@ class BleRadioTransportTest {
* [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep
* timeout in [MeshConnectionManagerImpl]).
*
- * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1
- * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms —
- * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24
- * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called
+ * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3): t = 1 000 ms — iteration 1 settle delay elapses,
+ * connectAndAwait throws, backoff 5 s starts t = 6 000 ms — backoff ends t = 7 000 ms — iteration 2 settle delay
+ * elapses, connectAndAwait throws, backoff 10 s starts t = 17 000 ms — backoff ends t = 18 000 ms — iteration 3
+ * settle delay elapses, connectAndAwait throws → onDisconnect called
*/
@Test
fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest {
@@ -120,10 +119,10 @@ class BleRadioTransportTest {
)
bleTransport.start()
- // Advance through exactly 3 failure iterations (≈24 001 ms virtual time).
+ // 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(24_001L)
+ advanceTimeBy(18_001L)
verify { service.onDisconnect(any(), any()) }
@@ -132,17 +131,16 @@ class BleRadioTransportTest {
}
/**
- * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected
- * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm —
- * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must
- * never call `onDisconnect(isPermanent = true)` from the give-up path.
+ * After [BleReconnectPolicy.DEFAULT_MAX_FAILURES] (10) consecutive failures, the reconnect loop should stop and
+ * signal a permanent disconnect. This prevents infinite battery drain when the device is genuinely offline.
*
- * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw +
- * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s
- * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance.
+ * Time budget for 10 failures with bonded device (no scan): Each iteration = 1s settle + connectAndAwait throw +
+ * backoff Backoffs: 5s, 10s, 20s, 40s, 60s, 60s, 60s, 60s, 60s, (exit at failure 10 before backoff) Total ≈ 10×1s
+ * settle + 5+10+20+40+60+60+60+60+60 = 10 + 375 = 385s ≈ 385_000ms We use a generous 400_000ms to cover any timing
+ * variance.
*/
@Test
- fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest {
+ fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest {
val device = FakeBleDevice(address = address, name = "Test Device")
bluetoothRepository.bond(device)
@@ -160,13 +158,11 @@ class BleRadioTransportTest {
)
bleTransport.start()
- // Run well past where the legacy policy (maxFailures = 10) would have given up.
- advanceTimeBy(800_001L)
+ // Advance enough time for all 10 failures to occur.
+ advanceTimeBy(400_001L)
- // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit;
- // the policy must NEVER signal a permanent disconnect on its own. Only explicit close()
- // (verified separately by the service layer) may emit isPermanent = true.
- verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) }
+ // Should have been called with isPermanent=true at least once (the final call).
+ verify { service.onDisconnect(isPermanent = true, errorMessage = any()) }
bleTransport.close()
}
diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
index 202d8de57..354c4cd30 100644
--- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
+++ b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
@@ -78,11 +78,7 @@ open class TcpRadioTransport(
Logger.d { "[$address] Closing TCP transport" }
closing = true
transport.stop()
- // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the
- // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting
- // it from close() caused a double-disconnect and prevented the auto-reconnect loop from
- // owning its own lifecycle. The `closing` guard above suppresses the listener's transient
- // disconnect during teardown.
+ callback.onDisconnect(isPermanent = true)
}
override fun keepAlive() {
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
index 45ba70eb7..a3f34d67e 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
@@ -129,10 +129,7 @@ private constructor(
// Ignore errors during port close
}
if (isActive) {
- // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as
- // transient — the user did not explicitly disconnect, and the port may come
- // back when the device is replugged or the OS re-enumerates it.
- onDeviceDisconnect(waitForStopped = true, isPermanent = false)
+ onDeviceDisconnect(true)
}
}
}
@@ -172,10 +169,8 @@ private constructor(
private const val READ_TIMEOUT_MS = 100
/**
- * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient
- * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as
- * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the
- * user grants permission); only an explicit close should signal a permanent disconnect.
+ * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a permanent
+ * disconnect to the [callback] and returns the (non-connected) instance.
*/
fun open(
portName: String,
@@ -188,7 +183,7 @@ private constructor(
if (!transport.startConnection()) {
val errorMessage = diagnoseOpenFailure(portName)
Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
- callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
+ callback.onDisconnect(isPermanent = true, errorMessage = errorMessage)
}
return transport
}
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index f69e137d9..cdf34f2d3 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -486,17 +486,8 @@
Честотен слот
Игнориране на MQTT
Конфигуриране на MQTT
- Неактивен
Прекъсната връзка
- Свързване…
Свързано
- Повторно свързване…
- Повторно свързване (опит %1$d) — %2$s
- Тестване на връзката
- Достъпен. Брокерът е приел идентификационните данни.
- Достъпен (%1$s)
- Хостът не е намерен
- Връзката е неуспешна
MQTT е активиран
Адрес
Потребителско име
@@ -978,5 +969,4 @@
Meshtastic
Филтър
Изберете устройство
- Изберете мрежа
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 4755515ad..8e97b008f 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -609,23 +609,8 @@
MQTT ignorieren
OK für MQTT
MQTT Einstellungen
- Inaktiv
Verbindung getrennt
- Verbindung getrennt - %1$s
- Wird verbunden
Verbunden
- Erneut verbinden
- Erneut verbinden (Versuch %1$d) - %2$s
- Verbindung testen
- Broker prüfen.
- Erreichbar. Broker akzeptierte Anmeldedaten.
- Erreichbar (%1$s)
- Broker abgelehnt: %1$s
- Host nicht gefunden
- Broker (TCP) nicht erreichbar
- TLS Handshake fehlgeschlagen
- Zeitüberschreitung nach %1$d ms
- Verbindung fehlgeschlagen
MQTT aktiviert
Adresse
Benutzername
@@ -1221,21 +1206,7 @@
Netzwerk eingeben oder auswählen
WLAN erfolgreich konfiguriert!
WLAN Konfiguration konnte nicht angewendet werden
- Meshtastic Desktop
- Meshtastic anzeigen
- Beenden
Meshtastic
- TAK Datenpaket exportieren
- Zeitzone löschen
Filter
- Filter entfernen
- Legende für Luftqualität anzeigen
- Nachrichtenstatus anzeigen
- Antwort senden
- Nachricht kopieren
- Nachricht auswählen
- Nachricht löschen
- Mit Emoji reagieren
Gerät auswählen
- Wählen Sie ein Netzwerk
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index c2e327629..be6376d0c 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -611,21 +611,9 @@
MQTT sätted
Mitteaktiivne
Ühendus katkenud
- Ühendus katkenud — %1$s
Ühendan…
Ühendatud
Taas ühendan…
- Ühendan uuesti (katse %1$d) — %2$s
- Test ühendus
- Kontrollin vahendajat…
- Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave.
- Kättesaadav (%1$s)
- Vahendaja lükkas tagasi: %1$s
- Hosti ei leitud
- Vahendajaga ei saa ühendust (TCP)
- TLS ühendus ebaõnnestus
- Ajaline katkestus peale %1$d ms
- Ühendus ebaõnnestus
MQTT lubatud
Aadress
Kasutajatunnus
@@ -1225,17 +1213,5 @@
Näita Meshtastic
Sule
Kärgvõrgustik
- Ekspordi TAK andmepakett
- Eemalda ajatsoon
Filtreeri
- Eemalda filter
- Näita õhukvaliteedi ajalugu
- Kuva sõnumi olek
- Saada vastus
- Kopeeri sõnum
- Vali sõnum
- Kustuta sõnum
- Vasta emotikoniga
- Vali seade
- Vali võrk
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index f9da71dea..c3bc3dc9e 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -611,21 +611,9 @@
MQTT asetukset
Passiivinen
Ei yhdistetty
- Yhteys katkaistu — %1$s
Yhdistetään…
Yhdistetty
Yhdistetään uudelleen…
- Yhdistetään uudelleen (yritys %1$d) — %2$s
- Testaa yhteys
- Tarkistetaan välityspalvelinta…
- Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot.
- Yhteys onnistui (%1$s)
- Välityspalvelin ei hyväksynyt: %1$s
- Palvelinta ei löytynyt
- Yhteyttä välityspalvelimeen ei saada (TCP)
- TLS-yhteyden muodostus epäonnistui
- Aikakatkaistu %1$d ms jälkeen
- Yhdistäminen epäonnistui
MQTT käytössä
Osoite
Käyttäjänimi
@@ -1226,17 +1214,6 @@
Näytä Meshtastic
Lopeta
Meshtastic
- Vie TAK-datapaketti
- Tyhjennä aikavyöhyke
Suodatus
- Poista suodatin
- Näytä ilmanlaadun selite
- Näytä viestin tila
- Lähetä vastaus
- Kopioi viesti
- Valitse viesti
- Poista viesti
- Reaktio emojin kanssa
Valitse laite
- Valitse verkko
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index f4afeef5c..b9c28e4cc 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -614,8 +614,6 @@
Connexion…
Connecté
Reconnexion…
- Test de la connexion
- Échec de la connexion
MQTT activé
Adresse
Nom d'utilisateur
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index 8d4590e82..cabfcd63f 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -617,23 +617,8 @@
Игнорировать MQTT
ОК в MQTT
Настройка MQTT
- Неактивно
Отключено
- Отключено — %1$s
- Подключение...
Подключено
- Переподключение...
- Переподключение (попытка %1$d) — %2$s
- Проверить соединение
- Проверяем брокер…
- Доступно. Брокер принял учетные данные.
- Доступно (%1$s)
- Брокер отклонен: %1$s
- Узел не найден
- Не удается подключиться к брокеру (TCP)
- Сбой TLS-рукопожатия
- Тайм-аут после %1$d мс
- Соединение не удалось
MQTT включен
Адрес
Имя пользователя
@@ -1237,21 +1222,7 @@
Введите или выберите сеть
Wi-Fi успешно настроен!
Не удалось применить настройку Wi-Fi
- Meshtastic Desktop
- Показать Meshtastic
- Выход
Meshtastic
- Экспорт пакета данных TAK
- Очистить часовой пояс
Фильтр
- Удалить фильтр
- Показать легенду качества воздуха
- Показать статус сообщения
- Отправить ответ
- Скопировать сообщение
- Выбрать сообщение
- Удалить сообщение
- Отреагировать эмодзи
Выберите устройство
- Выбрать сеть
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 59e19f1e5..7d81b8a8c 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -43,7 +43,6 @@
Okänd
Inväntar kvittens
Kvittens köad
- Levererad till nät
Okänd
Kvitterad
Ingen rutt
@@ -371,7 +370,6 @@
Varaktighet: %1$s s
Rutt spårad mot destination:\n\n
Rutten spårad tillbaka till oss:\n\n
- Inget svar
1h
24T
1V
@@ -530,8 +528,6 @@
MQTT-konfiguration
Frånkopplad
Ansluten
- Testa anslutningen
- Anslutningen misslyckades
MQTT är aktiverat
Adress
Användarnamn
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index c9a86af43..b2034aae1 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -401,7 +401,6 @@
Налаштування MQTT
Відключено
Під’єднано
- Перевірка зʼєднання
MQTT увімкнений
Адреса
Ім'я користувача
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index 7fff0db20..992e58187 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -567,7 +567,6 @@
MQTT设置
已断开连接
已连接
- 连接测试
启用MQTT
地址
用户名
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 20ee6c639..a6313dae7 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -222,11 +222,6 @@
%1$s: %2$s
來自 %1$s 的訊息:%2$s
標頭
- 標尾
- 點形
- 文字
- 儀表板
- 梯度
這是一個一個一個可客製化的組合元件
還支援多行文字與多種樣式
訊息傳遞狀態
@@ -412,12 +407,6 @@
回程跳數
來回跳數
無回應
- 1分鐘負載
- 5分鐘負載
- 15分鐘負載
- 1分鐘系統負載平均值
- 5分鐘系統負載平均值
- 15分鐘系統負載平均值
可用系統記憶體(位元組)
1小時
二十四小時
@@ -603,23 +592,8 @@
無視MQTT
允許轉發至 MQTT
MQTT配置
- 已停用
已中斷連線
- 已斷線 — %1$s
- 正在連接…
已連線
- 重新連接中…
- 重新連接中(第 %1$d 次嘗試) — %2$s
- 測試連線
- 正在查詢 Broker…
- 可供連線,Broker 已驗證並接受憑證。
- 可供連線(%1$s)
- Broker 遭拒:%1$s
- 找不到伺服器
- 無法連線至 Broker 中繼伺服器(TCP)
- TLS 握手失敗
- 經過 %1$d 毫秒後逾時
- 測試失敗
啟用MQTT服務器
地址
用戶名
@@ -818,9 +792,6 @@
顯示路徑
顯示定位精準度
客户端通知
- 金鑰驗證
- 金鑰驗證請求
- 金鑰驗證已完成
偵測到重複的公鑰
偵測到加密金鑰強度不足
偵測到金鑰已洩漏,點選確定後重新產生金鑰。
@@ -1189,8 +1160,6 @@
注意
裝置儲存空間與使用者介面(唯讀)
主題 %1$s,語言 %2$s
- 可使用檔案(%1$d):
- - %1$s(%2$d 位元)
未發現任何檔案。
連線
完成
@@ -1199,7 +1168,6 @@
進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS
正在搜尋裝置…
找到裝置
- 準備好掃描 Wi-Fi 網路了。
搜尋網路
正在搜尋…
正在套用 Wi-Fi 設定…
@@ -1212,21 +1180,9 @@
手動輸入或選擇一個網路
Wi-Fi 已設定完成!
無法套用 Wi-Fi 設定
- Meshtastic Desktop
顯示 Meshtastic
離開
Meshtastic
- 匯出 TAK 資料封包
- 清除時區
過濾器
- 移除篩選條件
- 顯示空氣品質圖例
- 顯示訊息狀態
- 傳送回覆
- 複製訊息
- 選擇訊息
- 刪除訊息
- 使用表情符號回應
選擇裝置
- 選擇網路
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
index b6999aadc..b0a3d738c 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/domain/usecase/AndroidGetDiscoveredDevicesUseCase.kt
@@ -60,14 +60,7 @@ class AndroidGetDiscoveredDevicesUseCase(
override fun invoke(showMock: Boolean): Flow {
val nodeDb = nodeRepository.nodeDBbyNum
- // Filter out non-Meshtastic peripherals (headphones, cars, watches, etc.).
- // BluetoothAdapter.bondedDevices returns every bonded device on the phone, so we
- // must restrict the picker to entries whose advertised name matches the
- // Meshtastic firmware pattern (see MeshtasticBleConstants.BLE_NAME_PATTERN).
- val bondedBleFlow =
- bluetoothRepository.state.map { ble ->
- ble.bondedDevices.filter { it.getMeshtasticShortName() != null }.map { DeviceListEntry.Ble(it) }
- }
+ val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
val processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt
index 559582417..9ce025604 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt
@@ -43,7 +43,10 @@ internal fun handleNodeAction(
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
- is NodeMenuAction.Remove -> viewModel.handleNodeMenuAction(menuAction, onNavigateUp)
+ is NodeMenuAction.Remove -> {
+ viewModel.handleNodeMenuAction(menuAction)
+ onNavigateUp()
+ }
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
index e891d8ae0..733cd858c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
@@ -89,10 +89,9 @@ class NodeDetailViewModel(
}
/** Dispatches high-level node management actions like removal, muting, or favoriting. */
- fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) {
+ fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
- is NodeMenuAction.Remove ->
- nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove)
+ is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
index 9c021e666..436954201 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
@@ -50,14 +50,11 @@ constructor(
private val radioController: RadioController,
private val alertManager: AlertManager,
) {
- open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
+ open fun requestRemoveNode(scope: CoroutineScope, node: Node) {
alertManager.showAlert(
titleRes = Res.string.remove,
messageRes = Res.string.remove_node_text,
- onConfirm = {
- removeNode(scope, node.num)
- onAfterRemove()
- },
+ onConfirm = { removeNode(scope, node.num) },
)
}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt
deleted file mode 100644
index 6bca8822b..000000000
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * 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.node.detail
-
-import androidx.lifecycle.SavedStateHandle
-import dev.mokkery.answering.returns
-import dev.mokkery.every
-import dev.mokkery.matcher.any
-import dev.mokkery.mock
-import dev.mokkery.verify
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.feature.node.component.NodeMenuAction
-import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
-import org.meshtastic.feature.node.model.NodeDetailAction
-import org.meshtastic.proto.User
-import kotlin.test.AfterTest
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertFalse
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class HandleNodeActionTest {
-
- private val testDispatcher = UnconfinedTestDispatcher()
- private val nodeManagementActions: NodeManagementActions = mock()
- private val nodeRequestActions: NodeRequestActions = mock()
- private val serviceRepository: ServiceRepository = mock()
- private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
-
- @BeforeTest
- fun setUp() {
- Dispatchers.setMain(testDispatcher)
- every { getNodeDetailsUseCase(any()) } returns emptyFlow()
- }
-
- @AfterTest
- fun tearDown() {
- Dispatchers.resetMain()
- }
-
- @Test
- fun `remove action delegates to viewModel and does not navigate up immediately`() = runTest(testDispatcher) {
- val node = Node(num = 1234, user = User(id = "!1234"))
- every { nodeManagementActions.requestRemoveNode(any(), any(), any()) } returns Unit
- val viewModel = createViewModel()
- var navigateUpCalled = false
-
- handleNodeAction(
- action = NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(node)),
- uiState = NodeDetailUiState(),
- navigateToMessages = {},
- onNavigateUp = { navigateUpCalled = true },
- onNavigate = {},
- viewModel = viewModel,
- )
-
- verify { nodeManagementActions.requestRemoveNode(any(), node, any()) }
- assertFalse(navigateUpCalled)
- }
-
- private fun createViewModel() = NodeDetailViewModel(
- savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)),
- nodeManagementActions = nodeManagementActions,
- nodeRequestActions = nodeRequestActions,
- serviceRepository = serviceRepository,
- getNodeDetailsUseCase = getNodeDetailsUseCase,
- )
-}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
index 3212a313e..89015c807 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
@@ -30,7 +30,6 @@ import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
import kotlin.test.Test
-import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest {
@@ -70,23 +69,4 @@ class NodeManagementActionsTest {
)
}
}
-
- @Test
- fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() {
- val realAlertManager = AlertManager()
- val actionsWithRealAlert =
- NodeManagementActions(
- nodeRepository = nodeRepository,
- serviceRepository = serviceRepository,
- radioController = radioController,
- alertManager = realAlertManager,
- )
- val node = Node(num = 123, user = User(long_name = "Test Node"))
- var afterRemoveCalled = false
-
- actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true }
- realAlertManager.currentAlert.value?.onConfirm?.invoke()
-
- assertTrue(afterRemoveCalled)
- }
}
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
index c6cef8aa3..415e0e11d 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt
@@ -17,48 +17,22 @@
package org.meshtastic.feature.widget
import android.content.Context
-import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.updateAll
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AppWidgetUpdater
-private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L
-
@Single
-class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) :
- AppWidgetUpdater {
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
-
- init {
- // Observe state changes and trigger a widget re-render whenever the data changes.
- // Glance compositions are ephemeral — the widget cannot self-update via collectAsState()
- // alone, so we must call updateAll() externally to drive re-renders.
- @OptIn(FlowPreview::class)
- scope.launch {
- stateProvider.state
- .debounce(WIDGET_UPDATE_DEBOUNCE_MS)
- .distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) }
- .collect { if (hasWidgetInstances()) updateAll() }
- }
- }
-
- private suspend fun hasWidgetInstances(): Boolean =
- GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty()
-
+class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater {
override suspend fun updateAll() {
+ // Kickstart the widget composition.
+ // The widget internally uses collectAsState() and its own sampled StateFlow
+ // to drive updates automatically without excessive IPC and recreation.
@Suppress("TooGenericExceptionCaught")
try {
LocalStatsWidget().updateAll(context)
} catch (e: Exception) {
- Logger.e(e) { "Failed to update widgets" }
+ co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" }
}
}
}
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
index b8aca2664..ee40bd60b 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt
@@ -76,6 +76,8 @@ data class LocalStatsWidgetUiState(
val updateTimeMillis: Long = 0,
)
+private const val WIDGET_SUBSCRIPTION_TIMEOUT_MS = 5_000L
+
@Single
class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -98,7 +100,12 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos
.map { input ->
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
}
- .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState())
+ .distinctUntilChanged()
+ .stateIn(
+ scope = scope,
+ started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS),
+ initialValue = LocalStatsWidgetUiState(),
+ )
private data class StateInput(
val connectionState: ConnectionState,
diff --git a/feature/widget/src/main/res/values/strings.xml b/feature/widget/src/main/res/values/strings.xml
deleted file mode 100644
index 1e47c86ee..000000000
--- a/feature/widget/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- Meshtastic
-
diff --git a/feature/widget/src/main/res/xml/widget_local_stats_info.xml b/feature/widget/src/main/res/xml/widget_local_stats_info.xml
index 6dde1ea1e..da9863cd9 100644
--- a/feature/widget/src/main/res/xml/widget_local_stats_info.xml
+++ b/feature/widget/src/main/res/xml/widget_local_stats_info.xml
@@ -16,7 +16,6 @@
~ along with this program. If not, see .
-->