mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: BLE transport and UI for Kotlin Multiplatform unification (#4911)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b0e91a390c
commit
6516287c62
42 changed files with 429 additions and 845 deletions
|
|
@ -1,367 +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.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.retryBleOperation
|
||||
import org.meshtastic.core.ble.toMeshtasticRadioProfile
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.RadioNotConnectedException
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
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 val SCAN_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* A [RadioTransport] implementation for BLE devices using Kable for desktop.
|
||||
*
|
||||
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
|
||||
* - Bonding and discovery.
|
||||
* - Automatic reconnection logic.
|
||||
* - MTU and connection parameter monitoring.
|
||||
* - Routing raw byte packets between the radio and [RadioInterfaceService].
|
||||
*
|
||||
* @param serviceScope The coroutine scope to use for launching coroutines.
|
||||
* @param scanner The BLE scanner.
|
||||
* @param bluetoothRepository The Bluetooth repository.
|
||||
* @param connectionFactory The BLE connection factory.
|
||||
* @param service The [RadioInterfaceService] to use for handling radio events.
|
||||
* @param address The BLE address of the device to connect to.
|
||||
*/
|
||||
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
@Suppress("TooManyFunctions", "TooGenericExceptionCaught", "SwallowedException")
|
||||
class DesktopBleInterface(
|
||||
private val serviceScope: CoroutineScope,
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val service: RadioInterfaceService,
|
||||
val address: String,
|
||||
) : RadioTransport {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
|
||||
serviceScope.launch {
|
||||
try {
|
||||
bleConnection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
|
||||
}
|
||||
}
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private val connectionScope: CoroutineScope =
|
||||
CoroutineScope(
|
||||
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
|
||||
)
|
||||
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
|
||||
private val writeMutex: Mutex = Mutex()
|
||||
|
||||
private var connectionStartTime: Long = 0
|
||||
private var packetsReceived: Int = 0
|
||||
private var packetsSent: Int = 0
|
||||
private var bytesReceived: Long = 0
|
||||
private var bytesSent: Long = 0
|
||||
|
||||
init {
|
||||
connect()
|
||||
}
|
||||
|
||||
// --- Connection & Discovery Logic ---
|
||||
|
||||
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
|
||||
private suspend fun findDevice(): BleDevice {
|
||||
bluetoothRepository.state.value.bondedDevices
|
||||
.firstOrNull { it.address == address }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
Logger.i { "[$address] Device not found in bonded list, scanning..." }
|
||||
|
||||
repeat(SCAN_RETRY_COUNT) { attempt ->
|
||||
try {
|
||||
val d =
|
||||
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
|
||||
scanner.scan(SCAN_TIMEOUT).first { it.address == address }
|
||||
}
|
||||
if (d != null) return d
|
||||
} catch (e: Exception) {
|
||||
// Ignore timeout exceptions
|
||||
}
|
||||
|
||||
if (attempt < SCAN_RETRY_COUNT - 1) {
|
||||
delay(SCAN_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
throw RadioNotConnectedException("Device not found at address $address")
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
connectionScope.launch {
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
if (state is BleConnectionState.Disconnected) {
|
||||
onDisconnected(state)
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(connectionScope)
|
||||
|
||||
while (isActive) {
|
||||
try {
|
||||
// Add a delay to allow any pending background disconnects (from a previous close() call)
|
||||
// to complete before we attempt a new connection.
|
||||
@Suppress("MagicNumber")
|
||||
val connectDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(connectDelayMs)
|
||||
|
||||
connectionStartTime = nowMillis
|
||||
Logger.i { "[$address] BLE connection attempt started" }
|
||||
|
||||
val device = findDevice()
|
||||
|
||||
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
if (state !is BleConnectionState.Connected) {
|
||||
throw RadioNotConnectedException("Failed to connect to device at address $address")
|
||||
}
|
||||
|
||||
onConnected()
|
||||
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) {
|
||||
Logger.d { "[$address] BLE connection coroutine cancelled" }
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureTime = nowMillis - connectionStartTime
|
||||
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
|
||||
handleFailure(e)
|
||||
|
||||
// Wait before retrying to prevent hot loops
|
||||
@Suppress("MagicNumber")
|
||||
kotlinx.coroutines.delay(5000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onConnected() {
|
||||
try {
|
||||
bleConnection.deviceFlow.first()?.let { device ->
|
||||
val rssi = retryBleOperation(tag = address) { device.readRssi() }
|
||||
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
|
||||
radioService = null
|
||||
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.w {
|
||||
"[$address] BLE disconnected, " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"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")
|
||||
}
|
||||
|
||||
private suspend fun discoverServicesAndSetupCharacteristics() {
|
||||
try {
|
||||
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
|
||||
val radioService = service.toMeshtasticRadioProfile()
|
||||
|
||||
// Wire up notifications
|
||||
radioService.fromRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in fromRadio flow" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
radioService.logRadio
|
||||
.onEach { packet ->
|
||||
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
|
||||
dispatchPacket(packet)
|
||||
}
|
||||
.catch { e ->
|
||||
Logger.w(e) { "[$address] Error in logRadio flow" }
|
||||
handleFailure(e)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
// Store reference for handleSendToRadio
|
||||
this@DesktopBleInterface.radioService = radioService
|
||||
|
||||
Logger.i { "[$address] Profile service active and characteristics subscribed" }
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
|
||||
|
||||
this@DesktopBleInterface.service.onConnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
|
||||
bleConnection.disconnect()
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
|
||||
|
||||
// --- RadioTransport Implementation ---
|
||||
|
||||
/**
|
||||
* Sends a packet to the radio with retry support.
|
||||
*
|
||||
* @param p The packet to send.
|
||||
*/
|
||||
override fun handleSendToRadio(p: ByteArray) {
|
||||
val currentService = radioService
|
||||
if (currentService != null) {
|
||||
connectionScope.launch {
|
||||
writeMutex.withLock {
|
||||
try {
|
||||
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
|
||||
packetsSent++
|
||||
bytesSent += p.size
|
||||
Logger.d {
|
||||
"[$address] Successfully wrote packet #$packetsSent " +
|
||||
"to toRadioCharacteristic - " +
|
||||
"${p.size} bytes (Total TX: $bytesSent bytes)"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) {
|
||||
"[$address] Failed to write packet to toRadioCharacteristic after " +
|
||||
"$packetsSent successful writes"
|
||||
}
|
||||
handleFailure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun keepAlive() {
|
||||
Logger.d { "[$address] BLE keepAlive" }
|
||||
}
|
||||
|
||||
/** Closes the connection to the device. */
|
||||
override fun close() {
|
||||
val uptime =
|
||||
if (connectionStartTime > 0) {
|
||||
nowMillis - connectionStartTime
|
||||
} else {
|
||||
0
|
||||
}
|
||||
Logger.i {
|
||||
"[$address] BLE close() called - " +
|
||||
"Uptime: ${uptime}ms, " +
|
||||
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
|
||||
"Packets TX: $packetsSent ($bytesSent bytes)"
|
||||
}
|
||||
serviceScope.launch {
|
||||
connectionScope.cancel()
|
||||
bleConnection.disconnect()
|
||||
service.onDisconnect(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchPacket(packet: ByteArray) {
|
||||
packetsReceived++
|
||||
bytesReceived += packet.size
|
||||
Logger.d {
|
||||
"[$address] Dispatching packet to service.handleFromRadio() - " +
|
||||
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
|
||||
}
|
||||
service.handleFromRadio(packet)
|
||||
}
|
||||
|
||||
private fun handleFailure(throwable: Throwable) {
|
||||
val (isPermanent, msg) = throwable.toDisconnectReason()
|
||||
service.onDisconnect(isPermanent, errorMessage = msg)
|
||||
}
|
||||
|
||||
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
|
||||
val isPermanent =
|
||||
this::class.simpleName == "BluetoothUnavailableException" ||
|
||||
this::class.simpleName == "ManagerClosedException"
|
||||
val msg =
|
||||
when {
|
||||
this is RadioNotConnectedException -> this.message ?: "Device not found"
|
||||
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
|
||||
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
|
||||
else -> this.message ?: this::class.simpleName ?: "Unknown"
|
||||
}
|
||||
return Pair(isPermanent, msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.desktop.radio
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
|
|
@ -23,55 +24,35 @@ import org.meshtastic.core.di.CoroutineDispatchers
|
|||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.network.SerialTransport
|
||||
import org.meshtastic.core.network.radio.BaseRadioTransportFactory
|
||||
import org.meshtastic.core.network.radio.TCPInterface
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
/**
|
||||
* Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing
|
||||
* platform-specific transports (USB/Serial) via jSerialComm.
|
||||
*/
|
||||
@Single(binds = [RadioTransportFactory::class])
|
||||
class DesktopRadioTransportFactory(
|
||||
private val scanner: BleScanner,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val connectionFactory: BleConnectionFactory,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : RadioTransportFactory {
|
||||
scanner: BleScanner,
|
||||
bluetoothRepository: BluetoothRepository,
|
||||
connectionFactory: BleConnectionFactory,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
|
||||
|
||||
override fun isMockInterface(): Boolean = false
|
||||
|
||||
override fun isAddressValid(address: String?): Boolean {
|
||||
val spec = address?.getOrNull(0) ?: return false
|
||||
return spec == InterfaceId.TCP.id ||
|
||||
spec == InterfaceId.SERIAL.id ||
|
||||
spec == InterfaceId.BLUETOOTH.id ||
|
||||
address.startsWith("!")
|
||||
}
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
|
||||
|
||||
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
|
||||
if (address.startsWith(InterfaceId.TCP.id)) {
|
||||
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
|
||||
address.startsWith(InterfaceId.TCP.id) -> {
|
||||
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
|
||||
} else if (address.startsWith(InterfaceId.SERIAL.id)) {
|
||||
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
|
||||
} else if (address.startsWith(InterfaceId.BLUETOOTH.id)) {
|
||||
DesktopBleInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
|
||||
)
|
||||
} else {
|
||||
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
|
||||
DesktopBleInterface(
|
||||
serviceScope = service.serviceScope,
|
||||
scanner = scanner,
|
||||
bluetoothRepository = bluetoothRepository,
|
||||
connectionFactory = connectionFactory,
|
||||
service = service,
|
||||
address = stripped,
|
||||
)
|
||||
}
|
||||
address.startsWith(InterfaceId.SERIAL.id) -> {
|
||||
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
|
||||
}
|
||||
else -> error("Unsupported transport for address: $address")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,12 +40,10 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.navigateTopLevel
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
|
||||
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
|
||||
import org.meshtastic.core.ui.component.MeshtasticAppShell
|
||||
import org.meshtastic.core.ui.navigation.icon
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.desktop.navigation.desktopNavGraph
|
||||
|
|
@ -80,57 +78,50 @@ fun DesktopMainScreen(
|
|||
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
MeshtasticCommonAppSetup(
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
|
||||
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
|
||||
},
|
||||
)
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = destination == selected,
|
||||
onClick = {
|
||||
if (destination != selected) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (destination == TopLevelDestination.Connections) {
|
||||
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
|
||||
connectionState = connectionState,
|
||||
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
|
||||
meshActivityFlow = radioService.meshActivity,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.label),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
MeshtasticAppShell(
|
||||
backStack = backStack,
|
||||
uiViewModel = uiViewModel,
|
||||
hostModifier = Modifier.padding(bottom = 24.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = destination == selected,
|
||||
onClick = {
|
||||
if (destination != selected) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (destination == TopLevelDestination.Connections) {
|
||||
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
|
||||
connectionState = connectionState,
|
||||
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
|
||||
meshActivityFlow = radioService.meshActivity,
|
||||
colorScheme = colorScheme,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.label),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MeshtasticSnackbarProvider(
|
||||
snackbarManager = uiViewModel.snackbarManager,
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
hostModifier = Modifier.padding(bottom = 24.dp),
|
||||
) {
|
||||
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = provider,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue