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 . -->