mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: remove demoscenario and enhance BLE connection stability (#4914)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
6516287c62
commit
8ce17defb7
10 changed files with 130 additions and 238 deletions
|
|
@ -21,8 +21,10 @@ package org.meshtastic.core.network.radio
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -53,6 +55,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 1000L
|
||||
private const val CONNECTION_TIMEOUT_MS = 15_000L
|
||||
private const val RECONNECT_FAILURE_THRESHOLD = 3
|
||||
private val SCAN_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
|
|
@ -106,9 +109,9 @@ class BleRadioInterface(
|
|||
private var bytesReceived: Long = 0
|
||||
private var bytesSent: Long = 0
|
||||
|
||||
@Suppress("VolatileModifier")
|
||||
@Volatile
|
||||
private var isFullyConnected = false
|
||||
@Volatile private var isFullyConnected = false
|
||||
private var connectionJob: Job? = null
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
init {
|
||||
connect()
|
||||
|
|
@ -148,24 +151,11 @@ class BleRadioInterface(
|
|||
}
|
||||
|
||||
private fun connect() {
|
||||
connectionScope.launch {
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
onDisconnected(state)
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
connectionJob = connectionScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Add a delay to allow any pending background disconnects (from a previous close() call)
|
||||
// to complete and the Android BLE stack to settle before we attempt a new connection.
|
||||
// Allow any pending background disconnects to complete and the Android BLE stack
|
||||
// to settle before we attempt a new connection.
|
||||
@Suppress("MagicNumber")
|
||||
val connectDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(connectDelayMs)
|
||||
|
|
@ -191,12 +181,30 @@ class BleRadioInterface(
|
|||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
// Connection succeeded — reset failure counter
|
||||
consecutiveFailures = 0
|
||||
isFullyConnected = true
|
||||
onConnected()
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
|
||||
// Suspend here until Kable drops the connection
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
// Use coroutineScope so that the connectionState listener is scoped to this
|
||||
// iteration only. When the inner scope exits (on disconnect), the listener is
|
||||
// cancelled automatically before the next reconnect cycle starts a fresh one.
|
||||
coroutineScope {
|
||||
bleConnection.connectionState
|
||||
.onEach { s ->
|
||||
if (s is BleConnectionState.Disconnected && isFullyConnected) {
|
||||
isFullyConnected = false
|
||||
onDisconnected()
|
||||
}
|
||||
}
|
||||
.catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" } }
|
||||
.launchIn(this)
|
||||
|
||||
discoverServicesAndSetupCharacteristics()
|
||||
|
||||
// Suspend here until Kable drops the connection
|
||||
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
|
||||
}
|
||||
|
||||
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
|
|
@ -204,8 +212,17 @@ class BleRadioInterface(
|
|||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
handleFailure(e)
|
||||
consecutiveFailures++
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to connect to device after ${failureTime}ms " +
|
||||
"(consecutive failures: $consecutiveFailures)"
|
||||
}
|
||||
|
||||
// After repeated failures, signal DeviceSleep so MeshConnectionManagerImpl can
|
||||
// start its sleep timeout. handleFailure covers permanent-error cases.
|
||||
if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
|
||||
handleFailure(e)
|
||||
}
|
||||
|
||||
// Wait before retrying to prevent hot loops
|
||||
@Suppress("MagicNumber")
|
||||
|
|
@ -226,7 +243,7 @@ class BleRadioInterface(
|
|||
}
|
||||
}
|
||||
|
||||
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
|
||||
private fun onDisconnected() {
|
||||
radioService = null
|
||||
|
||||
val uptime =
|
||||
|
|
@ -241,10 +258,10 @@ class BleRadioInterface(
|
|||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
|
||||
// Note: Disconnected state in commonMain doesn't currently carry a reason.
|
||||
// We might want to add that later if needed.
|
||||
service.onDisconnect(false, errorMessage = "Disconnected")
|
||||
// Do NOT call service.onDisconnect() here. The reconnect while-loop handles retries
|
||||
// internally. Emitting DeviceSleep on every transient disconnect creates competing state
|
||||
// transitions with MeshConnectionManagerImpl's sleep timeout. Instead, handleFailure()
|
||||
// is called from the catch block after RECONNECT_FAILURE_THRESHOLD consecutive failures.
|
||||
}
|
||||
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
|
|
@ -348,9 +365,15 @@ class BleRadioInterface(
|
|||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
// Cancel the connection scope FIRST to break the while(isActive) reconnect loop,
|
||||
// then perform async cleanup on the parent serviceScope.
|
||||
connectionScope.cancel("close() called")
|
||||
serviceScope.launch {
|
||||
connectionScope.cancel()
|
||||
bleConnection.disconnect()
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in close()" }
|
||||
}
|
||||
service.onDisconnect(true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ class RadioPrefsImpl(
|
|||
override val devAddr: StateFlow<String?> =
|
||||
dataStore.data.map { it[KEY_DEV_ADDR_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
override val devName: StateFlow<String?> =
|
||||
dataStore.data.map { it[KEY_DEV_NAME_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
override fun setDevAddr(address: String?) {
|
||||
scope.launch {
|
||||
dataStore.edit { prefs ->
|
||||
|
|
@ -54,7 +57,20 @@ class RadioPrefsImpl(
|
|||
}
|
||||
}
|
||||
|
||||
override fun setDevName(name: String?) {
|
||||
scope.launch {
|
||||
dataStore.edit { prefs ->
|
||||
if (name == null) {
|
||||
prefs.remove(KEY_DEV_NAME_PREF)
|
||||
} else {
|
||||
prefs[KEY_DEV_NAME_PREF] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val KEY_DEV_ADDR_PREF = stringPreferencesKey("devAddr2")
|
||||
val KEY_DEV_NAME_PREF = stringPreferencesKey("devName")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,12 @@ interface MapTileProviderPrefs {
|
|||
interface RadioPrefs {
|
||||
val devAddr: StateFlow<String?>
|
||||
|
||||
/** The persisted user-visible name of the connected device (e.g. "Meshtastic_1234"). */
|
||||
val devName: StateFlow<String?>
|
||||
|
||||
fun setDevAddr(address: String?)
|
||||
|
||||
fun setDevName(name: String?)
|
||||
}
|
||||
|
||||
fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ class SharedRadioInterfaceService(
|
|||
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private var radioIf: RadioTransport? = null
|
||||
private var runningInterfaceId: InterfaceId? = null
|
||||
private var isStarted = false
|
||||
|
||||
private val listenersInitialized = kotlinx.atomicfu.atomic(false)
|
||||
|
|
@ -111,6 +112,7 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
|
||||
private val initLock = Mutex()
|
||||
private val interfaceMutex = Mutex()
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized.value) return
|
||||
|
|
@ -121,19 +123,23 @@ class SharedRadioInterfaceService(
|
|||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
interfaceMutex.withLock {
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterfaceLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
|
||||
stopInterface()
|
||||
interfaceMutex.withLock {
|
||||
if (state.enabled) {
|
||||
startInterfaceLocked()
|
||||
} else if (runningInterfaceId == InterfaceId.BLUETOOTH) {
|
||||
stopInterfaceLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
|
|
@ -141,10 +147,12 @@ class SharedRadioInterfaceService(
|
|||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
|
||||
stopInterface()
|
||||
interfaceMutex.withLock {
|
||||
if (state) {
|
||||
startInterfaceLocked()
|
||||
} else if (runningInterfaceId == InterfaceId.TCP) {
|
||||
stopInterfaceLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
|
|
@ -154,7 +162,7 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
|
||||
override fun connect() {
|
||||
startInterface()
|
||||
processLifecycle.coroutineScope.launch { interfaceMutex.withLock { startInterfaceLocked() } }
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
|
|
@ -183,16 +191,22 @@ class SharedRadioInterfaceService(
|
|||
}
|
||||
|
||||
analytics.track("mesh_bond")
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
Logger.d { "Setting bonded device to ${sanitized?.anonymize}" }
|
||||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
startInterface()
|
||||
|
||||
processLifecycle.coroutineScope.launch {
|
||||
interfaceMutex.withLock {
|
||||
ignoreException { stopInterfaceLocked() }
|
||||
startInterfaceLocked()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun startInterface() {
|
||||
/** Must be called under [interfaceMutex]. */
|
||||
private fun startInterfaceLocked() {
|
||||
if (radioIf != null) return
|
||||
|
||||
val address =
|
||||
|
|
@ -206,15 +220,18 @@ class SharedRadioInterfaceService(
|
|||
|
||||
Logger.i { "Starting radio interface for ${address.anonymize}" }
|
||||
isStarted = true
|
||||
runningInterfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
|
||||
radioIf = transportFactory.createTransport(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
/** Must be called under [interfaceMutex]. */
|
||||
private fun stopInterfaceLocked() {
|
||||
val currentIf = radioIf
|
||||
Logger.i { "Stopping interface $currentIf" }
|
||||
isStarted = false
|
||||
radioIf = null
|
||||
runningInterfaceId = null
|
||||
currentIf?.close()
|
||||
|
||||
_serviceScope.cancel("stopping interface")
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
package org.meshtastic.desktop
|
||||
|
||||
import org.meshtastic.core.common.util.Base64Factory
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.UrlUtils
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.model.util.SfppHasher
|
||||
import org.meshtastic.core.model.util.getShortDateTime
|
||||
import org.meshtastic.core.model.util.platformRandomBytes
|
||||
|
||||
/**
|
||||
* Exercises key shared KMP modules to validate the module graph links and runs correctly on a pure JVM target without
|
||||
* Android framework dependencies.
|
||||
*/
|
||||
object DemoScenario {
|
||||
|
||||
@Suppress("LongMethod")
|
||||
fun renderReport(): String = buildString {
|
||||
appendLine("=".repeat(SEPARATOR_WIDTH))
|
||||
appendLine(" Meshtastic Desktop — KMP Shared Module Smoke Report")
|
||||
appendLine("=".repeat(SEPARATOR_WIDTH))
|
||||
appendLine()
|
||||
|
||||
// 1. core:common — Base64Factory
|
||||
section("core:common — Base64Factory") {
|
||||
val original = "Hello Meshtastic KMP!"
|
||||
val encoded = Base64Factory.encode(original.encodeToByteArray())
|
||||
val decoded = Base64Factory.decode(encoded).decodeToString()
|
||||
appendLine(" Original: $original")
|
||||
appendLine(" Encoded: $encoded")
|
||||
appendLine(" Decoded: $decoded")
|
||||
appendLine(" Round-trip: ${if (original == decoded) "✓ PASS" else "✗ FAIL"}")
|
||||
}
|
||||
|
||||
// 2. core:common — NumberFormatter
|
||||
@Suppress("MagicNumber")
|
||||
section("core:common — NumberFormatter") {
|
||||
appendLine(" format(3.14159, 2) = ${NumberFormatter.format(3.14159, 2)}")
|
||||
appendLine(" format(-0.5f, 1) = ${NumberFormatter.format(-0.5f, 1)}")
|
||||
appendLine(" format(100.0, 0) = ${NumberFormatter.format(100.0, 0)}")
|
||||
}
|
||||
|
||||
// 3. core:common — UrlUtils
|
||||
section("core:common — UrlUtils") {
|
||||
val raw = "hello world&foo=bar"
|
||||
appendLine(" encode(\"$raw\") = ${UrlUtils.encode(raw)}")
|
||||
}
|
||||
|
||||
// 4. core:common — DateFormatter
|
||||
section("core:common — DateFormatter") {
|
||||
val now = System.currentTimeMillis()
|
||||
appendLine(" formatTime(now) = ${DateFormatter.formatTime(now)}")
|
||||
appendLine(" formatDate(now) = ${DateFormatter.formatDate(now)}")
|
||||
appendLine(" formatRelativeTime(now) = ${DateFormatter.formatRelativeTime(now)}")
|
||||
appendLine(" formatDateTimeShort(now) = ${DateFormatter.formatDateTimeShort(now)}")
|
||||
}
|
||||
|
||||
// 5. core:common — CommonUri
|
||||
section("core:common — CommonUri") {
|
||||
val uri = CommonUri.parse("https://meshtastic.org/e/#test?foo=bar&enabled=true")
|
||||
appendLine(" host = ${uri.host}")
|
||||
appendLine(" fragment = ${uri.fragment}")
|
||||
appendLine(" segments = ${uri.pathSegments}")
|
||||
appendLine(" foo = ${uri.getQueryParameter("foo")}")
|
||||
appendLine(" enabled = ${uri.getBooleanQueryParameter("enabled", false)}")
|
||||
}
|
||||
|
||||
// 6. core:model — DeviceVersion
|
||||
section("core:model — DeviceVersion") {
|
||||
val v1 = DeviceVersion("2.5.3.abc1234")
|
||||
val v2 = DeviceVersion("2.6.0.def5678")
|
||||
appendLine(" v1 = $v1")
|
||||
appendLine(" v2 = $v2")
|
||||
appendLine(" v1 < v2 = ${v1 < v2}")
|
||||
}
|
||||
|
||||
// 7. core:model — Capabilities
|
||||
section("core:model — Capabilities") {
|
||||
val caps = Capabilities(firmwareVersion = "2.6.0.abc1234")
|
||||
appendLine(" firmwareVersion = ${caps.firmwareVersion}")
|
||||
}
|
||||
|
||||
// 8. core:model — SfppHasher
|
||||
section("core:model — SfppHasher") {
|
||||
val hash =
|
||||
SfppHasher.computeMessageHash(
|
||||
encryptedPayload = "test payload".encodeToByteArray(),
|
||||
to = 0x12345678,
|
||||
from = 0xABCDEF00.toInt(),
|
||||
id = 42,
|
||||
)
|
||||
appendLine(" hash length = ${hash.size}")
|
||||
appendLine(" hash (hex) = ${hash.joinToString("") { "%02x".format(it) }}")
|
||||
}
|
||||
|
||||
// 9. core:model — platformRandomBytes
|
||||
section("core:model — platformRandomBytes") {
|
||||
val random = platformRandomBytes(KEY_SIZE)
|
||||
appendLine(" ${random.size} random bytes (hex) = ${random.joinToString("") { "%02x".format(it) }}")
|
||||
}
|
||||
|
||||
// 10. core:model — getShortDateTime
|
||||
section("core:model — getShortDateTime") {
|
||||
appendLine(" getShortDateTime(now) = ${getShortDateTime(System.currentTimeMillis())}")
|
||||
}
|
||||
|
||||
// 11. core:model — Channel key generation
|
||||
section("core:model — Channel.getRandomKey") {
|
||||
val key = Channel.getRandomKey()
|
||||
appendLine(" Random channel key (${key.size} bytes)")
|
||||
}
|
||||
|
||||
appendLine()
|
||||
appendLine("=".repeat(SEPARATOR_WIDTH))
|
||||
appendLine(" All checks completed successfully")
|
||||
appendLine("=".repeat(SEPARATOR_WIDTH))
|
||||
}
|
||||
|
||||
private fun StringBuilder.section(title: String, block: StringBuilder.() -> Unit) {
|
||||
appendLine("─── $title")
|
||||
block()
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private const val SEPARATOR_WIDTH = 60
|
||||
private const val KEY_SIZE = 16
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
package org.meshtastic.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Validates that the KMP shared module graph runs correctly on JVM without Android. */
|
||||
class DemoScenarioTest {
|
||||
|
||||
@Test
|
||||
fun `renderReport produces non-empty output and completes successfully`() {
|
||||
val report = DemoScenario.renderReport()
|
||||
assertTrue(report.isNotBlank(), "Report should not be blank")
|
||||
assertTrue(report.contains("All checks completed successfully"), "Report should indicate success")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderReport exercises Base64 round-trip`() {
|
||||
val report = DemoScenario.renderReport()
|
||||
assertTrue(report.contains("✓ PASS"), "Base64 round-trip should pass")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renderReport exercises NumberFormatter`() {
|
||||
val report = DemoScenario.renderReport()
|
||||
assertTrue(report.contains("format(3.14159, 2) = 3.14"), "NumberFormatter should format correctly")
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import org.meshtastic.core.model.RadioController
|
|||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.UsbRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
|
|
@ -40,6 +41,7 @@ class AndroidScannerViewModel(
|
|||
serviceRepository: ServiceRepository,
|
||||
radioController: RadioController,
|
||||
radioInterfaceService: RadioInterfaceService,
|
||||
radioPrefs: RadioPrefs,
|
||||
recentAddressesDataSource: RecentAddressesDataSource,
|
||||
getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
|
||||
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
|
||||
|
|
@ -50,6 +52,7 @@ class AndroidScannerViewModel(
|
|||
serviceRepository,
|
||||
radioController,
|
||||
radioInterfaceService,
|
||||
radioPrefs,
|
||||
recentAddressesDataSource,
|
||||
getDiscoveredDevicesUseCase,
|
||||
dispatchers,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import org.meshtastic.core.datastore.model.RecentAddress
|
|||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||
|
|
@ -49,6 +50,7 @@ open class ScannerViewModel(
|
|||
protected val serviceRepository: ServiceRepository,
|
||||
private val radioController: RadioController,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val recentAddressesDataSource: RecentAddressesDataSource,
|
||||
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
|
||||
private val dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
|
||||
|
|
@ -150,6 +152,9 @@ open class ScannerViewModel(
|
|||
|
||||
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
|
||||
|
||||
/** The persisted device name from the last selection, for use as a UI fallback. */
|
||||
val persistedDeviceName: StateFlow<String?> = radioPrefs.devName
|
||||
|
||||
val selectedNotNullFlow: StateFlow<String> =
|
||||
selectedAddressFlow
|
||||
.map { it ?: NO_DEVICE_SELECTED }
|
||||
|
|
@ -193,6 +198,7 @@ open class ScannerViewModel(
|
|||
fun onSelected(it: DeviceListEntry): Boolean = when (it) {
|
||||
is DeviceListEntry.Ble -> {
|
||||
if (it.bonded) {
|
||||
radioPrefs.setDevName(it.name)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
true
|
||||
} else {
|
||||
|
|
@ -202,6 +208,7 @@ open class ScannerViewModel(
|
|||
}
|
||||
is DeviceListEntry.Usb -> {
|
||||
if (it.bonded) {
|
||||
radioPrefs.setDevName(it.name)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
true
|
||||
} else {
|
||||
|
|
@ -211,12 +218,14 @@ open class ScannerViewModel(
|
|||
}
|
||||
is DeviceListEntry.Tcp -> {
|
||||
viewModelScope.launch {
|
||||
radioPrefs.setDevName(it.name)
|
||||
addRecentAddress(it.fullAddress, it.name)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
}
|
||||
true
|
||||
}
|
||||
is DeviceListEntry.Mock -> {
|
||||
radioPrefs.setDevName(it.name)
|
||||
changeDeviceAddress(it.fullAddress)
|
||||
true
|
||||
}
|
||||
|
|
@ -228,6 +237,7 @@ open class ScannerViewModel(
|
|||
protected open fun requestPermission(entry: DeviceListEntry.Usb) {}
|
||||
|
||||
fun disconnect() {
|
||||
radioPrefs.setDevName(null)
|
||||
changeDeviceAddress(NO_DEVICE_SELECTED)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ fun ConnectionsScreen(
|
|||
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
|
||||
|
||||
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
||||
val persistedDeviceName by scanModel.persistedDeviceName.collectAsStateWithLifecycle()
|
||||
|
||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
|
||||
|
|
@ -194,6 +195,7 @@ fun ConnectionsScreen(
|
|||
1 ->
|
||||
ConnectingDeviceContent(
|
||||
selectedDevice = selectedDevice,
|
||||
persistedDeviceName = persistedDeviceName,
|
||||
bleDevices = bleDevices,
|
||||
discoveredTcpDevices = discoveredTcpDevices,
|
||||
recentTcpDevices = recentTcpDevices,
|
||||
|
|
@ -327,6 +329,7 @@ private fun ConnectedDeviceContent(
|
|||
@Composable
|
||||
private fun ConnectingDeviceContent(
|
||||
selectedDevice: String,
|
||||
persistedDeviceName: String?,
|
||||
bleDevices: List<DeviceListEntry>,
|
||||
discoveredTcpDevices: List<DeviceListEntry>,
|
||||
recentTcpDevices: List<DeviceListEntry>,
|
||||
|
|
@ -339,7 +342,9 @@ private fun ConnectingDeviceContent(
|
|||
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
|
||||
?: usbDevices.find { it.fullAddress == selectedDevice }
|
||||
|
||||
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
|
||||
// Use the entry name if found in scan lists, otherwise fall back to the persisted name
|
||||
// from the last successful selection, and only show "Unknown Device" as a last resort.
|
||||
val name = selectedEntry?.name ?: persistedDeviceName ?: stringResource(Res.string.unknown_device)
|
||||
val address = selectedEntry?.address ?: selectedDevice
|
||||
|
||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.FakeServiceRepository
|
||||
import org.meshtastic.feature.connections.model.DiscoveredDevices
|
||||
|
|
@ -43,6 +44,7 @@ class ScannerViewModelTest {
|
|||
private val serviceRepository = FakeServiceRepository()
|
||||
private val radioController = FakeRadioController()
|
||||
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private val radioPrefs: RadioPrefs = mock(MockMode.autofill)
|
||||
private val recentAddressesDataSource: RecentAddressesDataSource = mock(MockMode.autofill)
|
||||
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase = mock(MockMode.autofill)
|
||||
private val bleScanner: org.meshtastic.core.ble.BleScanner = mock(MockMode.autofill)
|
||||
|
|
@ -66,6 +68,7 @@ class ScannerViewModelTest {
|
|||
serviceRepository = serviceRepository,
|
||||
radioController = radioController,
|
||||
radioInterfaceService = radioInterfaceService,
|
||||
radioPrefs = radioPrefs,
|
||||
recentAddressesDataSource = recentAddressesDataSource,
|
||||
getDiscoveredDevicesUseCase = getDiscoveredDevicesUseCase,
|
||||
dispatchers =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue