mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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
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:
parent
ae4465d7c8
commit
c75c9b34d6
43 changed files with 1100 additions and 120 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, _ ->
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue