Compare commits

..

10 commits

Author SHA1 Message Date
James Rich
f21d8af9ae
fix(transport): improve BLE / TCP / USB reconnect and handshake resilience (#5196)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 17:34:16 +00:00
James Rich
a90cb2d89e
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5195)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-20 17:32:58 +00:00
Copilot
7492a33cf8
Fix node-details remove action to preserve confirmation flow (#5192)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jamesarich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 15:59:20 +00:00
James Rich
2b47da3b61
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5193)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-20 07:40:08 -05:00
renovate[bot]
3322257cfd
chore(deps): update plugin com.gradle.develocity to v4.4.1 (#5194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 11:47:09 +00:00
James Rich
99e7407a90
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5189)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-19 20:07:52 +00:00
renovate[bot]
9dd57725f2
chore(deps): update vico to v3.2.0-next.1 (#5191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 12:31:11 -05:00
renovate[bot]
2c1984ace5
chore(deps): update fastlane to v2.233.0 (#5190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-19 16:30:34 +00:00
James Rich
94856d257f
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5186)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-04-18 12:09:22 +00:00
James Rich
84fe24467f
fix(widget): drive updates via debounced state observer (#5185)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-18 04:11:32 +00:00
37 changed files with 488 additions and 94 deletions

1
.gitignore vendored
View file

@ -55,3 +55,4 @@ wireless-install.sh
firebase-debug.log
.agent_plans/
.agent_refs/
.agent_artifacts/

View file

@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.0)
aws-partitions (1.1240.0)
aws-sdk-core (3.245.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.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-s3 (1.219.0)
aws-sdk-core (~> 3, >= 3.244.0)
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.0.1)
bigdecimal (4.1.2)
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.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.2)
fastimage (2.4.1)
fastlane (2.233.0)
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.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@ -122,10 +122,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.95.0)
google-apis-androidpublisher_v3 (0.99.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@ -139,15 +138,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.59.0)
google-apis-storage_v1 (0.61.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.5.0)
google-cloud-storage (1.58.0)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@ -169,13 +168,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.1)
json (2.19.4)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multi_json (1.20.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@ -185,13 +184,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.2)
rake (13.3.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@ -205,7 +204,6 @@ 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)

View file

@ -59,6 +59,7 @@ 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
@ -91,6 +92,8 @@ 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.
@ -166,6 +169,16 @@ 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(
@ -257,6 +270,11 @@ 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()
}

View file

@ -30,7 +30,7 @@ pluginManagement {
}
plugins {
id("com.gradle.develocity") version("4.4.0")
id("com.gradle.develocity") version("4.4.1")
}
dependencyResolutionManagement {

View file

@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
/**
* Classification of a BLE-layer exception for the transport layer to act on.
*
* @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
* @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 gattStatus the platform GATT status code when available (Android-specific).
* @property message a human-readable description of the failure.
*/
@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
is GattRequestRejectedException ->
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
is UnmetRequirementException ->
BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
// 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")
else -> null
}

View file

@ -60,6 +60,7 @@ 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
@ -211,11 +212,11 @@ class MeshConnectionManagerImpl(
}
}
private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
delay(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
@ -291,13 +292,13 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
startHandshakeStallGuard(1, action)
startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, action)
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
action()
}
@ -404,7 +405,14 @@ class MeshConnectionManagerImpl(
*/
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
private val HANDSHAKE_TIMEOUT = 30.seconds
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
// 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

View file

@ -108,7 +108,10 @@ class SerialRadioTransport(
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
onDeviceDisconnect(false)
// 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)
}
},
)

View file

@ -87,6 +87,11 @@ 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

View file

@ -54,9 +54,7 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
buildMap {
serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
}
buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@ -83,6 +81,8 @@ class UsbRepository(
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
private suspend fun refreshStateInternal() =
withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
_serialDevices.emit(devices)
}
}

View file

@ -133,7 +133,11 @@ class BleRadioTransport(
@Volatile private var isFullyConnected = false
private var connectionJob: Job? = null
private val reconnectPolicy = BleReconnectPolicy()
// 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 heartbeatSender =
HeartbeatSender(

View file

@ -26,10 +26,11 @@ import kotlin.time.Duration.Companion.seconds
/**
* Encapsulates the BLE reconnection policy with exponential backoff.
*
* The policy tracks consecutive failures and decides whether to retry, signal a transient disconnect (DeviceSleep), or
* give up permanently.
* 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.
*
* @param maxFailures maximum consecutive failures before giving up permanently
* @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
* @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"
@ -148,7 +149,18 @@ class BleReconnectPolicy(
companion object {
const val DEFAULT_MAX_FAILURES = 10
const val DEFAULT_FAILURE_THRESHOLD = 3
val DEFAULT_SETTLE_DELAY = 1.seconds
/**
* 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
* disconnectreconnect cycles, 3/54/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_MIN_STABLE_CONNECTION = 5.seconds
internal val RECONNECT_BASE_DELAY = 5.seconds

View file

@ -37,18 +37,20 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p
override suspend fun close() {
Logger.d { "Closing stream for good" }
onDeviceDisconnect(true)
onDeviceDisconnect(waitForStopped = true, isPermanent = true)
}
/**
* Notify the transport callback that our device has gone away, but wait for it to come back.
* Signals the transport callback that the device has disconnected and optionally waits for the transport to stop.
*
* @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside
* transport callbacks
* @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.
* @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.
*/
protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = true) {
protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) {
callback.onDisconnect(isPermanent = isPermanent)
}

View file

@ -22,6 +22,7 @@ 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
@ -95,10 +96,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): 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
* 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
*/
@Test
fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest {
@ -119,10 +120,10 @@ class BleRadioTransportTest {
)
bleTransport.start()
// Advance through exactly 3 failure iterations (≈18 001 ms virtual time).
// Advance through exactly 3 failure iterations (≈24 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)
advanceTimeBy(24_001L)
verify { service.onDisconnect(any(), any()) }
@ -131,16 +132,17 @@ class BleRadioTransportTest {
}
/**
* 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.
* 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.
*
* 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.
* 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.
*/
@Test
fun `reconnect loop stops after DEFAULT_MAX_FAILURES with permanent disconnect`() = runTest {
fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest {
val device = FakeBleDevice(address = address, name = "Test Device")
bluetoothRepository.bond(device)
@ -158,11 +160,13 @@ class BleRadioTransportTest {
)
bleTransport.start()
// Advance enough time for all 10 failures to occur.
advanceTimeBy(400_001L)
// Run well past where the legacy policy (maxFailures = 10) would have given up.
advanceTimeBy(800_001L)
// Should have been called with isPermanent=true at least once (the final call).
verify { service.onDisconnect(isPermanent = true, errorMessage = any()) }
// 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()) }
bleTransport.close()
}

View file

@ -78,7 +78,11 @@ open class TcpRadioTransport(
Logger.d { "[$address] Closing TCP transport" }
closing = true
transport.stop()
callback.onDisconnect(isPermanent = true)
// 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.
}
override fun keepAlive() {

View file

@ -129,7 +129,10 @@ private constructor(
// Ignore errors during port close
}
if (isActive) {
onDeviceDisconnect(true)
// 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)
}
}
}
@ -169,8 +172,10 @@ private constructor(
private const val READ_TIMEOUT_MS = 100
/**
* 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.
* 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.
*/
fun open(
portName: String,
@ -183,7 +188,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 = true, errorMessage = errorMessage)
callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
}
return transport
}

View file

@ -486,8 +486,17 @@
<string name="frequency_slot">Честотен слот</string>
<string name="ignore_mqtt">Игнориране на MQTT</string>
<string name="mqtt_config">Конфигуриране на MQTT</string>
<string name="mqtt_status_inactive">Неактивен</string>
<string name="mqtt_status_disconnected">Прекъсната връзка</string>
<string name="mqtt_status_connecting">Свързване…</string>
<string name="mqtt_status_connected">Свързано</string>
<string name="mqtt_status_reconnecting">Повторно свързване…</string>
<string name="mqtt_status_reconnecting_with_attempt">Повторно свързване (опит %1$d) — %2$s</string>
<string name="mqtt_test_connection">Тестване на връзката</string>
<string name="mqtt_probe_success">Достъпен. Брокерът е приел идентификационните данни.</string>
<string name="mqtt_probe_success_with_info">Достъпен (%1$s)</string>
<string name="mqtt_probe_dns_failure">Хостът не е намерен</string>
<string name="mqtt_probe_other_failure">Връзката е неуспешна</string>
<string name="mqtt_enabled">MQTT е активиран</string>
<string name="address">Адрес</string>
<string name="username">Потребителско име</string>
@ -969,4 +978,5 @@
<string name="desktop_notification_title">Meshtastic</string>
<string name="filter_icon">Филтър</string>
<string name="action_select_device">Изберете устройство</string>
<string name="action_select_network">Изберете мрежа</string>
</resources>

View file

@ -609,8 +609,23 @@
<string name="ignore_mqtt">MQTT ignorieren</string>
<string name="ok_to_mqtt">OK für MQTT</string>
<string name="mqtt_config">MQTT Einstellungen</string>
<string name="mqtt_status_inactive">Inaktiv</string>
<string name="mqtt_status_disconnected">Verbindung getrennt</string>
<string name="mqtt_status_disconnected_with_reason">Verbindung getrennt - %1$s</string>
<string name="mqtt_status_connecting">Wird verbunden</string>
<string name="mqtt_status_connected">Verbunden</string>
<string name="mqtt_status_reconnecting">Erneut verbinden</string>
<string name="mqtt_status_reconnecting_with_attempt">Erneut verbinden (Versuch %1$d) - %2$s</string>
<string name="mqtt_test_connection">Verbindung testen</string>
<string name="mqtt_probe_running">Broker prüfen.</string>
<string name="mqtt_probe_success">Erreichbar. Broker akzeptierte Anmeldedaten.</string>
<string name="mqtt_probe_success_with_info">Erreichbar (%1$s)</string>
<string name="mqtt_probe_rejected">Broker abgelehnt: %1$s</string>
<string name="mqtt_probe_dns_failure">Host nicht gefunden</string>
<string name="mqtt_probe_tcp_failure">Broker (TCP) nicht erreichbar</string>
<string name="mqtt_probe_tls_failure">TLS Handshake fehlgeschlagen</string>
<string name="mqtt_probe_timeout">Zeitüberschreitung nach %1$d ms</string>
<string name="mqtt_probe_other_failure">Verbindung fehlgeschlagen</string>
<string name="mqtt_enabled">MQTT aktiviert</string>
<string name="address">Adresse</string>
<string name="username">Benutzername</string>
@ -1206,7 +1221,21 @@
<string name="wifi_provision_ssid_placeholder">Netzwerk eingeben oder auswählen</string>
<string name="wifi_provision_status_applied">WLAN erfolgreich konfiguriert!</string>
<string name="wifi_provision_status_failed">WLAN Konfiguration konnte nicht angewendet werden</string>
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
<string name="desktop_tray_show">Meshtastic anzeigen</string>
<string name="desktop_tray_quit">Beenden</string>
<string name="desktop_notification_title">Meshtastic</string>
<string name="export_tak_data_package">TAK Datenpaket exportieren</string>
<string name="clear_time_zone">Zeitzone löschen</string>
<string name="filter_icon">Filter</string>
<string name="remove_filter">Filter entfernen</string>
<string name="show_iaq_legend">Legende für Luftqualität anzeigen</string>
<string name="action_show_message_status">Nachrichtenstatus anzeigen</string>
<string name="action_send_reply">Antwort senden</string>
<string name="action_copy_message">Nachricht kopieren</string>
<string name="action_select_message">Nachricht auswählen</string>
<string name="action_delete_message">Nachricht löschen</string>
<string name="action_react_with_emoji">Mit Emoji reagieren</string>
<string name="action_select_device">Gerät auswählen</string>
<string name="action_select_network">Wählen Sie ein Netzwerk</string>
</resources>

View file

@ -611,9 +611,21 @@
<string name="mqtt_config">MQTT sätted</string>
<string name="mqtt_status_inactive">Mitteaktiivne</string>
<string name="mqtt_status_disconnected">Ühendus katkenud</string>
<string name="mqtt_status_disconnected_with_reason">Ühendus katkenud — %1$s</string>
<string name="mqtt_status_connecting">Ühendan…</string>
<string name="mqtt_status_connected">Ühendatud</string>
<string name="mqtt_status_reconnecting">Taas ühendan…</string>
<string name="mqtt_status_reconnecting_with_attempt">Ühendan uuesti (katse %1$d) — %2$s</string>
<string name="mqtt_test_connection">Test ühendus</string>
<string name="mqtt_probe_running">Kontrollin vahendajat…</string>
<string name="mqtt_probe_success">Ühendus õnnestus. Vahendaja aktsepteeris kasutajateave.</string>
<string name="mqtt_probe_success_with_info">Kättesaadav (%1$s)</string>
<string name="mqtt_probe_rejected">Vahendaja lükkas tagasi: %1$s</string>
<string name="mqtt_probe_dns_failure">Hosti ei leitud</string>
<string name="mqtt_probe_tcp_failure">Vahendajaga ei saa ühendust (TCP)</string>
<string name="mqtt_probe_tls_failure">TLS ühendus ebaõnnestus</string>
<string name="mqtt_probe_timeout">Ajaline katkestus peale %1$d ms</string>
<string name="mqtt_probe_other_failure">Ühendus ebaõnnestus</string>
<string name="mqtt_enabled">MQTT lubatud</string>
<string name="address">Aadress</string>
<string name="username">Kasutajatunnus</string>
@ -1213,5 +1225,17 @@
<string name="desktop_tray_show">Näita Meshtastic</string>
<string name="desktop_tray_quit">Sule</string>
<string name="desktop_notification_title">Kärgvõrgustik</string>
<string name="export_tak_data_package">Ekspordi TAK andmepakett</string>
<string name="clear_time_zone">Eemalda ajatsoon</string>
<string name="filter_icon">Filtreeri</string>
<string name="remove_filter">Eemalda filter</string>
<string name="show_iaq_legend">Näita õhukvaliteedi ajalugu</string>
<string name="action_show_message_status">Kuva sõnumi olek</string>
<string name="action_send_reply">Saada vastus</string>
<string name="action_copy_message">Kopeeri sõnum</string>
<string name="action_select_message">Vali sõnum</string>
<string name="action_delete_message">Kustuta sõnum</string>
<string name="action_react_with_emoji">Vasta emotikoniga</string>
<string name="action_select_device">Vali seade</string>
<string name="action_select_network">Vali võrk</string>
</resources>

View file

@ -611,9 +611,21 @@
<string name="mqtt_config">MQTT asetukset</string>
<string name="mqtt_status_inactive">Passiivinen</string>
<string name="mqtt_status_disconnected">Ei yhdistetty</string>
<string name="mqtt_status_disconnected_with_reason">Yhteys katkaistu — %1$s</string>
<string name="mqtt_status_connecting">Yhdistetään…</string>
<string name="mqtt_status_connected">Yhdistetty</string>
<string name="mqtt_status_reconnecting">Yhdistetään uudelleen…</string>
<string name="mqtt_status_reconnecting_with_attempt">Yhdistetään uudelleen (yritys %1$d) — %2$s</string>
<string name="mqtt_test_connection">Testaa yhteys</string>
<string name="mqtt_probe_running">Tarkistetaan välityspalvelinta…</string>
<string name="mqtt_probe_success">Yhteys onnistui. Välityspalvelin hyväksyi tunnistetiedot.</string>
<string name="mqtt_probe_success_with_info">Yhteys onnistui (%1$s)</string>
<string name="mqtt_probe_rejected">Välityspalvelin ei hyväksynyt: %1$s</string>
<string name="mqtt_probe_dns_failure">Palvelinta ei löytynyt</string>
<string name="mqtt_probe_tcp_failure">Yhteyttä välityspalvelimeen ei saada (TCP)</string>
<string name="mqtt_probe_tls_failure">TLS-yhteyden muodostus epäonnistui</string>
<string name="mqtt_probe_timeout">Aikakatkaistu %1$d ms jälkeen</string>
<string name="mqtt_probe_other_failure">Yhdistäminen epäonnistui</string>
<string name="mqtt_enabled">MQTT käytössä</string>
<string name="address">Osoite</string>
<string name="username">Käyttäjänimi</string>
@ -1214,6 +1226,17 @@
<string name="desktop_tray_show">Näytä Meshtastic</string>
<string name="desktop_tray_quit">Lopeta</string>
<string name="desktop_notification_title">Meshtastic</string>
<string name="export_tak_data_package">Vie TAK-datapaketti</string>
<string name="clear_time_zone">Tyhjennä aikavyöhyke</string>
<string name="filter_icon">Suodatus</string>
<string name="remove_filter">Poista suodatin</string>
<string name="show_iaq_legend">Näytä ilmanlaadun selite</string>
<string name="action_show_message_status">Näytä viestin tila</string>
<string name="action_send_reply">Lähetä vastaus</string>
<string name="action_copy_message">Kopioi viesti</string>
<string name="action_select_message">Valitse viesti</string>
<string name="action_delete_message">Poista viesti</string>
<string name="action_react_with_emoji">Reaktio emojin kanssa</string>
<string name="action_select_device">Valitse laite</string>
<string name="action_select_network">Valitse verkko</string>
</resources>

View file

@ -614,6 +614,8 @@
<string name="mqtt_status_connecting">Connexion…</string>
<string name="mqtt_status_connected">Connecté</string>
<string name="mqtt_status_reconnecting">Reconnexion…</string>
<string name="mqtt_test_connection">Test de la connexion</string>
<string name="mqtt_probe_other_failure">Échec de la connexion</string>
<string name="mqtt_enabled">MQTT activé</string>
<string name="address">Adresse</string>
<string name="username">Nom d'utilisateur</string>

View file

@ -617,8 +617,23 @@
<string name="ignore_mqtt">Игнорировать MQTT</string>
<string name="ok_to_mqtt">ОК в MQTT</string>
<string name="mqtt_config">Настройка MQTT</string>
<string name="mqtt_status_inactive">Неактивно</string>
<string name="mqtt_status_disconnected">Отключено</string>
<string name="mqtt_status_disconnected_with_reason">Отключено — %1$s</string>
<string name="mqtt_status_connecting">Подключение...</string>
<string name="mqtt_status_connected">Подключено</string>
<string name="mqtt_status_reconnecting">Переподключение...</string>
<string name="mqtt_status_reconnecting_with_attempt">Переподключение (попытка %1$d) — %2$s</string>
<string name="mqtt_test_connection">Проверить соединение</string>
<string name="mqtt_probe_running">Проверяем брокер…</string>
<string name="mqtt_probe_success">Доступно. Брокер принял учетные данные.</string>
<string name="mqtt_probe_success_with_info">Доступно (%1$s)</string>
<string name="mqtt_probe_rejected">Брокер отклонен: %1$s</string>
<string name="mqtt_probe_dns_failure">Узел не найден</string>
<string name="mqtt_probe_tcp_failure">Не удается подключиться к брокеру (TCP)</string>
<string name="mqtt_probe_tls_failure">Сбой TLS-рукопожатия</string>
<string name="mqtt_probe_timeout">Тайм-аут после %1$d мс</string>
<string name="mqtt_probe_other_failure">Соединение не удалось</string>
<string name="mqtt_enabled">MQTT включен</string>
<string name="address">Адрес</string>
<string name="username">Имя пользователя</string>
@ -1222,7 +1237,21 @@
<string name="wifi_provision_ssid_placeholder">Введите или выберите сеть</string>
<string name="wifi_provision_status_applied">Wi-Fi успешно настроен!</string>
<string name="wifi_provision_status_failed">Не удалось применить настройку Wi-Fi</string>
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
<string name="desktop_tray_show">Показать Meshtastic</string>
<string name="desktop_tray_quit">Выход</string>
<string name="desktop_notification_title">Meshtastic</string>
<string name="export_tak_data_package">Экспорт пакета данных TAK</string>
<string name="clear_time_zone">Очистить часовой пояс</string>
<string name="filter_icon">Фильтр</string>
<string name="remove_filter">Удалить фильтр</string>
<string name="show_iaq_legend">Показать легенду качества воздуха</string>
<string name="action_show_message_status">Показать статус сообщения</string>
<string name="action_send_reply">Отправить ответ</string>
<string name="action_copy_message">Скопировать сообщение</string>
<string name="action_select_message">Выбрать сообщение</string>
<string name="action_delete_message">Удалить сообщение</string>
<string name="action_react_with_emoji">Отреагировать эмодзи</string>
<string name="action_select_device">Выберите устройство</string>
<string name="action_select_network">Выбрать сеть</string>
</resources>

View file

@ -43,6 +43,7 @@
<string name="unrecognized">Okänd</string>
<string name="message_status_enroute">Inväntar kvittens</string>
<string name="message_status_queued">Kvittens köad</string>
<string name="message_status_delivered">Levererad till nät</string>
<string name="message_status_unknown">Okänd</string>
<string name="routing_error_none">Kvitterad</string>
<string name="routing_error_no_route">Ingen rutt</string>
@ -370,6 +371,7 @@
<string name="traceroute_duration">Varaktighet: %1$s s</string>
<string name="traceroute_route_towards_dest">Rutt spårad mot destination:\n\n</string>
<string name="traceroute_route_back_to_us">Rutten spårad tillbaka till oss:\n\n</string>
<string name="traceroute_no_response">Inget svar</string>
<string name="one_hour_short">1h</string>
<string name="twenty_four_hours">24T</string>
<string name="one_week">1V</string>
@ -528,6 +530,8 @@
<string name="mqtt_config">MQTT-konfiguration</string>
<string name="mqtt_status_disconnected">Frånkopplad</string>
<string name="mqtt_status_connected">Ansluten</string>
<string name="mqtt_test_connection">Testa anslutningen</string>
<string name="mqtt_probe_other_failure">Anslutningen misslyckades</string>
<string name="mqtt_enabled">MQTT är aktiverat</string>
<string name="address">Adress</string>
<string name="username">Användarnamn</string>

View file

@ -401,6 +401,7 @@
<string name="mqtt_config">Налаштування MQTT</string>
<string name="mqtt_status_disconnected">Відключено</string>
<string name="mqtt_status_connected">Під’єднано</string>
<string name="mqtt_test_connection">Перевірка зʼєднання</string>
<string name="mqtt_enabled">MQTT увімкнений</string>
<string name="address">Адреса</string>
<string name="username">Ім'я користувача</string>

View file

@ -567,6 +567,7 @@
<string name="mqtt_config">MQTT设置</string>
<string name="mqtt_status_disconnected">已断开连接</string>
<string name="mqtt_status_connected">已连接</string>
<string name="mqtt_test_connection">连接测试</string>
<string name="mqtt_enabled">启用MQTT</string>
<string name="address">地址</string>
<string name="username">用户名</string>

View file

@ -222,6 +222,11 @@
<string name="a11y_label_value">%1$s: %2$s</string>
<string name="a11y_message_from">來自 %1$s 的訊息:%2$s</string>
<string name="preview_header">標頭</string>
<string name="preview_footer">標尾</string>
<string name="preview_dot">點形</string>
<string name="preview_text">文字</string>
<string name="preview_gauge">儀表板</string>
<string name="preview_gradient">梯度</string>
<string name="preview_custom_composable_line_one">這是一個一個一個可客製化的組合元件</string>
<string name="preview_custom_composable_line_two">還支援多行文字與多種樣式</string>
<string name="message_delivery_status">訊息傳遞狀態</string>
@ -407,6 +412,12 @@
<string name="traceroute_return_hops">回程跳數</string>
<string name="traceroute_round_trip">來回跳數</string>
<string name="traceroute_no_response">無回應</string>
<string name="load_1_min">1分鐘負載</string>
<string name="load_5_min">5分鐘負載</string>
<string name="load_15_min">15分鐘負載</string>
<string name="load_1_min_description">1分鐘系統負載平均值</string>
<string name="load_5_min_description">5分鐘系統負載平均值</string>
<string name="load_15_min_description">15分鐘系統負載平均值</string>
<string name="free_memory_description">可用系統記憶體(位元組)</string>
<string name="one_hour_short">1小時</string>
<string name="twenty_four_hours">二十四小時</string>
@ -592,8 +603,23 @@
<string name="ignore_mqtt">無視MQTT</string>
<string name="ok_to_mqtt">允許轉發至 MQTT</string>
<string name="mqtt_config">MQTT配置</string>
<string name="mqtt_status_inactive">已停用</string>
<string name="mqtt_status_disconnected">已中斷連線</string>
<string name="mqtt_status_disconnected_with_reason">已斷線 — %1$s</string>
<string name="mqtt_status_connecting">正在連接…</string>
<string name="mqtt_status_connected">已連線</string>
<string name="mqtt_status_reconnecting">重新連接中…</string>
<string name="mqtt_status_reconnecting_with_attempt">重新連接中(第 %1$d 次嘗試) — %2$s</string>
<string name="mqtt_test_connection">測試連線</string>
<string name="mqtt_probe_running">正在查詢 Broker…</string>
<string name="mqtt_probe_success">可供連線Broker 已驗證並接受憑證。</string>
<string name="mqtt_probe_success_with_info">可供連線(%1$s</string>
<string name="mqtt_probe_rejected">Broker 遭拒:%1$s</string>
<string name="mqtt_probe_dns_failure">找不到伺服器</string>
<string name="mqtt_probe_tcp_failure">無法連線至 Broker 中繼伺服器TCP</string>
<string name="mqtt_probe_tls_failure">TLS 握手失敗</string>
<string name="mqtt_probe_timeout">經過 %1$d 毫秒後逾時</string>
<string name="mqtt_probe_other_failure">測試失敗</string>
<string name="mqtt_enabled">啟用MQTT服務器</string>
<string name="address">地址</string>
<string name="username">用戶名</string>
@ -792,6 +818,9 @@
<string name="show_waypoints">顯示路徑</string>
<string name="show_precision_circle">顯示定位精準度</string>
<string name="client_notification">客户端通知</string>
<string name="key_verification_title">金鑰驗證</string>
<string name="key_verification_request_title">金鑰驗證請求</string>
<string name="key_verification_final_title">金鑰驗證已完成</string>
<string name="duplicated_public_key_title">偵測到重複的公鑰</string>
<string name="low_entropy_key_title">偵測到加密金鑰強度不足</string>
<string name="compromised_keys">偵測到金鑰已洩漏,點選確定後重新產生金鑰。</string>
@ -1160,6 +1189,8 @@
<string name="note">注意</string>
<string name="device_storage_ui_title">裝置儲存空間與使用者介面(唯讀)</string>
<string name="device_theme_language">主題 %1$s語言 %2$s</string>
<string name="files_available">可使用檔案(%1$d</string>
<string name="file_entry">- %1$s%2$d 位元)</string>
<string name="no_files_manifested">未發現任何檔案。</string>
<string name="connect">連線</string>
<string name="done">完成</string>
@ -1168,6 +1199,7 @@
<string name="wifi_provision_mpwrd_disclaimer">進一步了解 mPWRD-OS 專案\nhttps://github.com/mPWRD-OS</string>
<string name="wifi_provision_scanning_ble">正在搜尋裝置…</string>
<string name="wifi_provision_device_found">找到裝置</string>
<string name="wifi_provision_device_found_detail">準備好掃描 Wi-Fi 網路了。</string>
<string name="wifi_provision_scan_networks">搜尋網路</string>
<string name="wifi_provision_scanning_wifi">正在搜尋…</string>
<string name="wifi_provision_sending_credentials">正在套用 Wi-Fi 設定…</string>
@ -1180,9 +1212,21 @@
<string name="wifi_provision_ssid_placeholder">手動輸入或選擇一個網路</string>
<string name="wifi_provision_status_applied">Wi-Fi 已設定完成!</string>
<string name="wifi_provision_status_failed">無法套用 Wi-Fi 設定</string>
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
<string name="desktop_tray_show">顯示 Meshtastic</string>
<string name="desktop_tray_quit">離開</string>
<string name="desktop_notification_title">Meshtastic</string>
<string name="export_tak_data_package">匯出 TAK 資料封包</string>
<string name="clear_time_zone">清除時區</string>
<string name="filter_icon">過濾器</string>
<string name="remove_filter">移除篩選條件</string>
<string name="show_iaq_legend">顯示空氣品質圖例</string>
<string name="action_show_message_status">顯示訊息狀態</string>
<string name="action_send_reply">傳送回覆</string>
<string name="action_copy_message">複製訊息</string>
<string name="action_select_message">選擇訊息</string>
<string name="action_delete_message">刪除訊息</string>
<string name="action_react_with_emoji">使用表情符號回應</string>
<string name="action_select_device">選擇裝置</string>
<string name="action_select_network">選擇網路</string>
</resources>

View file

@ -60,7 +60,14 @@ class AndroidGetDiscoveredDevicesUseCase(
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
// 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 processedTcpFlow =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {

View file

@ -43,10 +43,7 @@ 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)
}
}

View file

@ -89,9 +89,10 @@ class NodeDetailViewModel(
}
/** Dispatches high-level node management actions like removal, muting, or favoriting. */
fun handleNodeMenuAction(action: NodeMenuAction) {
fun handleNodeMenuAction(action: NodeMenuAction, onAfterRemove: () -> Unit = {}) {
when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
is NodeMenuAction.Remove ->
nodeManagementActions.requestRemoveNode(viewModelScope, action.node, onAfterRemove)
is NodeMenuAction.Ignore -> nodeManagementActions.requestIgnoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.requestMuteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)

View file

@ -50,11 +50,14 @@ constructor(
private val radioController: RadioController,
private val alertManager: AlertManager,
) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
alertManager.showAlert(
titleRes = Res.string.remove,
messageRes = Res.string.remove_node_text,
onConfirm = { removeNode(scope, node.num) },
onConfirm = {
removeNode(scope, node.num)
onAfterRemove()
},
)
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.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,
)
}

View file

@ -30,6 +30,7 @@ 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 {
@ -69,4 +70,23 @@ 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)
}
}

View file

@ -17,22 +17,48 @@
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) : AppWidgetUpdater {
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()
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) {
co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" }
Logger.e(e) { "Failed to update widgets" }
}
}
}

View file

@ -76,8 +76,6 @@ 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())
@ -100,12 +98,7 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos
.map { input ->
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
}
.distinctUntilChanged()
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS),
initialValue = LocalStatsWidgetUiState(),
)
.stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState())
private data class StateInput(
val connectionState: ConnectionState,

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025-2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="widget_local_stats_label">Meshtastic</string>
</resources>

View file

@ -16,6 +16,7 @@
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:label="@string/widget_local_stats_label"
android:initialLayout="@layout/glance_default_loading_layout"
android:previewLayout="@layout/widget_local_stats_preview"
android:minWidth="110dp"

View file

@ -78,7 +78,7 @@ uri-kmp = "0.0.21"
osmdroid-android = "6.1.20"
spotless = "8.4.0"
wire = "6.2.0"
vico = "3.1.0"
vico = "3.2.0-next.1"
kable = "0.42.0"
mqttastic = "0.2.0"
jmdns = "3.6.3"

View file

@ -83,7 +83,7 @@ dependencyResolutionManagement {
plugins {
id("org.gradle.toolchains.foojay-resolver") version "1.0.0"
id("com.gradle.develocity") version("4.4.0")
id("com.gradle.develocity") version("4.4.1")
id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0"
}