feat: implement XModem file transfers and enhance BLE connection robustness (#4959)
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

This commit is contained in:
James Rich 2026-03-30 22:49:31 -05:00 committed by GitHub
parent ae4465d7c8
commit c75c9b34d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1100 additions and 120 deletions

View file

@ -35,7 +35,7 @@ import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@KoinViewModel
@KoinViewModel(binds = [ScannerViewModel::class])
@Suppress("LongParameterList", "TooManyFunctions")
class AndroidScannerViewModel(
serviceRepository: ServiceRepository,

View file

@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
@ -44,7 +43,6 @@ import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")
open class ScannerViewModel(
protected val serviceRepository: ServiceRepository,
@ -118,9 +116,14 @@ open class ScannerViewModel(
val bonded = discovered?.bleDevices?.filterIsInstance<DeviceListEntry.Ble>() ?: emptyList()
val bondedAddresses = bonded.map { it.address }.toSet()
// Add scanned devices that aren't already in the bonded list
// Add scanned devices that aren't already in the bonded list.
// These are explicitly marked as unbonded so the UI routes through
// requestBonding() — which on Android triggers createBond() for the
// pairing dialog before connecting.
val unbondedScanned =
scannedMap.values.filter { it.address !in bondedAddresses }.map { DeviceListEntry.Ble(it) }
scannedMap.values
.filter { it.address !in bondedAddresses }
.map { DeviceListEntry.Ble(device = it, bonded = false) }
// Sort by name
(bonded + unbondedScanned).sortedBy { it.name }
@ -231,8 +234,16 @@ open class ScannerViewModel(
}
}
/** Initiates the bonding process and connects to the device upon success. */
protected open fun requestBonding(entry: DeviceListEntry.Ble) {}
/**
* Initiates the bonding process and connects to the device upon success.
*
* The default implementation connects directly without explicit bonding, which is correct for Desktop/JVM where the
* OS Bluetooth stack handles pairing during the GATT connection. Android overrides this to call `createBond()`
* first.
*/
protected open fun requestBonding(entry: DeviceListEntry.Ble) {
changeDeviceAddress(entry.fullAddress)
}
protected open fun requestPermission(entry: DeviceListEntry.Usb) {}

View file

@ -39,14 +39,17 @@ sealed class DeviceListEntry(
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
data class Ble(val device: BleDevice, override val node: Node? = null) :
DeviceListEntry(
name = device.name ?: "unnamed-${device.address}",
fullAddress = "x${device.address}",
bonded = device.isBonded,
node = node,
) {
override fun copy(node: Node?): Ble = copy(device = device, node = node)
data class Ble(
val device: BleDevice,
override val bonded: Boolean = device.isBonded,
override val node: Node? = null,
) : DeviceListEntry(
name = device.name ?: "unnamed-${device.address}",
fullAddress = "x${device.address}",
bonded = bonded,
node = node,
) {
override fun copy(node: Node?): Ble = copy(device = device, bonded = bonded, node = node)
}
data class Usb(

View file

@ -0,0 +1,53 @@
/*
* 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.connections
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
/**
* Desktop/JVM [ScannerViewModel] registration.
*
* On Desktop, the base [ScannerViewModel] is used directly. The default [requestBonding] connects without explicit
* bonding since the OS Bluetooth stack handles pairing during the GATT connection.
*/
@KoinViewModel(binds = [ScannerViewModel::class])
@Suppress("LongParameterList")
class JvmScannerViewModel(
serviceRepository: ServiceRepository,
radioController: RadioController,
radioInterfaceService: RadioInterfaceService,
radioPrefs: RadioPrefs,
recentAddressesDataSource: RecentAddressesDataSource,
getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
dispatchers: org.meshtastic.core.di.CoroutineDispatchers,
bleScanner: org.meshtastic.core.ble.BleScanner? = null,
) : ScannerViewModel(
serviceRepository,
radioController,
radioInterfaceService,
radioPrefs,
recentAddressesDataSource,
getDiscoveredDevicesUseCase,
dispatchers,
bleScanner,
)

View file

@ -63,7 +63,7 @@ class BleOtaTransportTest {
every { device.name } returns "Test Device"
every { scanner.scan(any(), any()) } returns flowOf(device)
coEvery { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Disconnected
coEvery { connection.connectAndAwait(any(), any()) } returns BleConnectionState.Disconnected
val result = transport.connect()
assertTrue("Expected failure", result.isFailure)

View file

@ -71,6 +71,8 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
@ -92,6 +94,8 @@ data class RadioConfigState(
val ringtone: String = "",
val cannedMessageMessages: String = "",
val deviceConnectionStatus: DeviceConnectionStatus? = null,
val deviceUIConfig: DeviceUIConfig? = null,
val fileManifest: List<FileInfo> = emptyList(),
val responseState: ResponseState<Boolean> = ResponseState.Empty,
val analyticsAvailable: Boolean = true,
val analyticsEnabled: Boolean = false,
@ -188,6 +192,14 @@ open class RadioConfigViewModel(
}
.launchIn(viewModelScope)
radioConfigRepository.deviceUIConfigFlow
.onEach { uiConfig -> _radioConfigState.update { it.copy(deviceUIConfig = uiConfig) } }
.launchIn(viewModelScope)
radioConfigRepository.fileManifestFlow
.onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } }
.launchIn(viewModelScope)
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
combine(serviceRepository.connectionState, radioConfigState) { connState, _ ->

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@ -54,6 +55,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.isDebug
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.accept
import org.meshtastic.core.resources.are_you_sure
@ -66,11 +68,16 @@ import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summar
import org.meshtastic.core.resources.config_device_tzdef_summary
import org.meshtastic.core.resources.config_device_use_phone_tz
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.device_storage_ui_title
import org.meshtastic.core.resources.device_theme_language
import org.meshtastic.core.resources.double_tap_as_button_press
import org.meshtastic.core.resources.file_entry
import org.meshtastic.core.resources.files_available
import org.meshtastic.core.resources.gpio
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.led_heartbeat
import org.meshtastic.core.resources.no_files_manifested
import org.meshtastic.core.resources.nodeinfo_broadcast_interval
import org.meshtastic.core.resources.options
import org.meshtastic.core.resources.rebroadcast_mode
@ -143,6 +150,7 @@ private val Config.DeviceConfig.RebroadcastMode.description: StringResource
Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
Res.string.rebroadcast_mode_core_portnums_only_desc
else -> Res.string.unrecognized
}
@ -152,7 +160,7 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig()
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role) }
var selectedRole by rememberSaveable(formState.value.role) { mutableStateOf(formState.value.role) }
val infrastructureRoles =
listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
@ -309,6 +317,42 @@ fun DeviceConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Unit
)
}
}
if ((state.deviceUIConfig != null || state.fileManifest.isNotEmpty()) && isDebug) {
item {
TitledCard(title = stringResource(Res.string.device_storage_ui_title)) {
state.deviceUIConfig?.let { uiConfig ->
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text =
stringResource(
Res.string.device_theme_language,
uiConfig.theme.toString(),
uiConfig.language.toString(),
),
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp))
}
if (state.fileManifest.isNotEmpty()) {
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(Res.string.files_available, state.fileManifest.size),
)
state.fileManifest.forEach { file ->
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(Res.string.file_entry, file.file_name, file.size_bytes),
)
}
} else {
Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResource(Res.string.no_files_manifested),
)
}
}
}
}
}
}

View file

@ -102,7 +102,7 @@ fun PositionConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
updated
}
val formState = rememberConfigState(initialValue = sanitizedPositionConfig)
var locationInput by rememberSaveable { mutableStateOf(currentPosition) }
var locationInput by rememberSaveable(currentPosition) { mutableStateOf(currentPosition) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(

View file

@ -111,6 +111,12 @@ class RadioConfigViewModelTest {
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
every { radioConfigRepository.deviceUIConfigFlow } returns MutableStateFlow(null)
every { radioConfigRepository.fileManifestFlow } returns MutableStateFlow(emptyList())
every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(false)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { serviceRepository.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)