mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Compare commits
No commits in common. "main" and "v2.7.14-internal.63" have entirely different histories.
main
...
v2.7.14-in
37 changed files with 94 additions and 488 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -55,4 +55,3 @@ wireless-install.sh
|
|||
firebase-debug.log
|
||||
.agent_plans/
|
||||
.agent_refs/
|
||||
.agent_artifacts/
|
||||
|
|
|
|||
46
Gemfile.lock
46
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ pluginManagement {
|
|||
}
|
||||
|
||||
plugins {
|
||||
id("com.gradle.develocity") version("4.4.1")
|
||||
id("com.gradle.develocity") version("4.4.0")
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -486,17 +486,8 @@
|
|||
<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>
|
||||
|
|
@ -978,5 +969,4 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -609,23 +609,8 @@
|
|||
<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>
|
||||
|
|
@ -1221,21 +1206,7 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -611,21 +611,9 @@
|
|||
<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>
|
||||
|
|
@ -1225,17 +1213,5 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -611,21 +611,9 @@
|
|||
<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>
|
||||
|
|
@ -1226,17 +1214,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -614,8 +614,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -617,23 +617,8 @@
|
|||
<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>
|
||||
|
|
@ -1237,21 +1222,7 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
<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>
|
||||
|
|
@ -371,7 +370,6 @@
|
|||
<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>
|
||||
|
|
@ -530,8 +528,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -401,7 +401,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -567,7 +567,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -222,11 +222,6 @@
|
|||
<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>
|
||||
|
|
@ -412,12 +407,6 @@
|
|||
<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>
|
||||
|
|
@ -603,23 +592,8 @@
|
|||
<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>
|
||||
|
|
@ -818,9 +792,6 @@
|
|||
<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>
|
||||
|
|
@ -1189,8 +1160,6 @@
|
|||
<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>
|
||||
|
|
@ -1199,7 +1168,6 @@
|
|||
<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>
|
||||
|
|
@ -1212,21 +1180,9 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -60,14 +60,7 @@ class AndroidGetDiscoveredDevicesUseCase(
|
|||
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
~ 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"
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ uri-kmp = "0.0.21"
|
|||
osmdroid-android = "6.1.20"
|
||||
spotless = "8.4.0"
|
||||
wire = "6.2.0"
|
||||
vico = "3.2.0-next.1"
|
||||
vico = "3.1.0"
|
||||
kable = "0.42.0"
|
||||
mqttastic = "0.2.0"
|
||||
jmdns = "3.6.3"
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ dependencyResolutionManagement {
|
|||
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver") version "1.0.0"
|
||||
id("com.gradle.develocity") version("4.4.1")
|
||||
id("com.gradle.develocity") version("4.4.0")
|
||||
id("com.gradle.common-custom-user-data-gradle-plugin") version "2.6.0"
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue