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

@ -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)