feat(firmware): Confirm local firmware files before flashing (#6083)

This commit is contained in:
Jeremiah K
2026-07-08 17:36:14 -05:00
committed by GitHub
parent 8662a27e39
commit 8bfee2a59e
11 changed files with 1330 additions and 73 deletions

View File

@@ -591,9 +591,14 @@ firmware_recovery_explanation
firmware_too_old
firmware_update_almost_there
firmware_update_alpha
firmware_update_archive_missing_target
firmware_update_battery_low
firmware_update_checking
firmware_update_confirm_file_button
firmware_update_confirm_file_message
firmware_update_confirm_file_title
firmware_update_connecting_attempt
firmware_update_context_changed
firmware_update_copying
firmware_update_currently_installed
firmware_update_device
@@ -609,9 +614,11 @@ firmware_update_erasing
firmware_update_error
firmware_update_extracting
firmware_update_failed
firmware_update_filename_unavailable
firmware_update_flashing
firmware_update_hang_tight
firmware_update_hash_rejected
firmware_update_invalid_local_file_detail
firmware_update_keep_device_close
firmware_update_latest
firmware_update_local_file
@@ -619,6 +626,7 @@ firmware_update_method_ble
firmware_update_method_detail
firmware_update_method_usb
firmware_update_method_wifi
firmware_update_missing_target
firmware_update_no_device
firmware_update_node_info_missing
firmware_update_not_found_in_release
@@ -626,6 +634,9 @@ firmware_update_ota_failed
firmware_update_rak4631_bootloader_hint
firmware_update_rebooting
firmware_update_release_notes
firmware_update_requires_bin
firmware_update_requires_ota_zip
firmware_update_requires_uf2
firmware_update_retrieval_failed
firmware_update_retry
firmware_update_save_dfu_file
@@ -643,6 +654,7 @@ firmware_update_unknown_error
firmware_update_unknown_hardware
firmware_update_unknown_release
firmware_update_unsupported_transport
firmware_update_unsupported_update_method
firmware_update_uploading
firmware_update_usb_bootloader_warning
firmware_update_usb_failed

View File

@@ -615,9 +615,14 @@
<string name="firmware_too_old">Firmware update required.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
<string name="firmware_update_archive_missing_target">No firmware for %2$s (%3$s) was found in \"%1$s\".</string>
<string name="firmware_update_battery_low">Battery too low (%1$d%). Please charge your device before updating.</string>
<string name="firmware_update_checking">Checking for updates...</string>
<string name="firmware_update_confirm_file_button">Start Flashing</string>
<string name="firmware_update_confirm_file_message">Review the full selected filename before flashing. Flashing starts only after you confirm.</string>
<string name="firmware_update_confirm_file_title">Confirm Firmware File</string>
<string name="firmware_update_connecting_attempt">Connecting to device (attempt %1$d/%2$d)...</string>
<string name="firmware_update_context_changed">Device connection changed while confirming. Please reselect the firmware file.</string>
<string name="firmware_update_copying">High-five! Wait, copying firmware...</string>
<string name="firmware_update_currently_installed">Currently Installed: %1$s</string>
<string name="firmware_update_device">Device: %1$s</string>
@@ -633,9 +638,11 @@
<string name="firmware_update_error">Error: %1$s</string>
<string name="firmware_update_extracting">Extracting firmware...</string>
<string name="firmware_update_failed">Update failed</string>
<string name="firmware_update_filename_unavailable">Could not read the selected file name. Choose a local firmware file with a visible filename.</string>
<string name="firmware_update_flashing">Flashing device, please wait...</string>
<string name="firmware_update_hang_tight">Hang tight, we are working on it...</string>
<string name="firmware_update_hash_rejected">Firmware hash rejected. Device may require hash provisioning or bootloader update.</string>
<string name="firmware_update_invalid_local_file_detail">The selected file \"%1$s\" does not match %2$s (%3$s). Choose the firmware file for this target.</string>
<string name="firmware_update_keep_device_close">Keep your device close to your phone.</string>
<string name="firmware_update_latest">Update To: %1$s</string>
<string name="firmware_update_local_file">Local File</string>
@@ -643,6 +650,7 @@
<string name="firmware_update_method_detail">Update via %1$s</string>
<string name="firmware_update_method_usb">USB File Transfer</string>
<string name="firmware_update_method_wifi">WiFi OTA</string>
<string name="firmware_update_missing_target">Firmware target information is unavailable for this device.</string>
<string name="firmware_update_no_device">No device connected</string>
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
@@ -650,6 +658,9 @@
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
<string name="firmware_update_release_notes">Release Notes</string>
<string name="firmware_update_requires_bin">%1$s requires a target-matching .bin file.</string>
<string name="firmware_update_requires_ota_zip">%1$s requires a target-matching OTA .zip file.</string>
<string name="firmware_update_requires_uf2">%1$s requires a target-matching .uf2 file.</string>
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
<string name="firmware_update_retry">Retry</string>
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device's DFU drive.</string>
@@ -667,6 +678,7 @@
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
<string name="firmware_update_unknown_release">Unknown remote release</string>
<string name="firmware_update_unsupported_transport">Firmware updates are not supported over this connection for this device. Connect a different way (e.g. Bluetooth) to update.</string>
<string name="firmware_update_unsupported_update_method">Local firmware files are not supported for this update method.</string>
<string name="firmware_update_uploading">Uploading firmware...</string>
<string name="firmware_update_usb_bootloader_warning">%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.</string>
<string name="firmware_update_usb_failed">USB Update failed</string>

View File

@@ -17,6 +17,7 @@
package org.meshtastic.feature.firmware
import android.content.Context
import android.provider.OpenableColumns
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
import io.ktor.client.HttpClient
@@ -134,7 +135,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
preferredFilename: String?,
): FirmwareArtifact? = withContext(ioDispatcher) {
val localZipFile = zipFile.toLocalFileOrNull() ?: return@withContext null
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val target = hardware.effectiveTarget
if (target.isEmpty() && preferredFilename == null) return@withContext null
val targetLowerCase = target.lowercase()
@@ -169,6 +170,8 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
entry = zipInput.nextEntry
}
}
// Prefer the shortest matching entry name — official release bundles contain one
// matching firmware per target; the heuristic picks the canonical name if multiple match.
matchingEntries.minByOrNull { it.first.name.length }?.second?.toFirmwareArtifact()
}
@@ -178,7 +181,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
fileExtension: String,
preferredFilename: String?,
): FirmwareArtifact? = withContext(ioDispatcher) {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val target = hardware.effectiveTarget
if (target.isEmpty() && preferredFilename == null) return@withContext null
val targetLowerCase = target.lowercase()
@@ -255,6 +258,37 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
tempFile.toFirmwareArtifact()
}
override suspend fun getDisplayName(uri: CommonUri): String? = withContext(ioDispatcher) {
val platformUri = uri.toAndroidUri()
if (platformUri.scheme == "content") {
// query() can throw SecurityException (revoked permission) or IllegalArgumentException
// (malformed URI) even after the scheme guard; fall through to the file-scheme branch on failure.
runCatching {
context.contentResolver.query(
platformUri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)
}
.onFailure { e -> Logger.w(e) { "Failed to query display name from content provider" } }
.getOrNull()
?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
cursor
.getString(nameIndex)
?.takeIf { it.isNotBlank() }
?.let {
return@withContext it
}
}
}
}
if (platformUri.scheme == "file") uri.pathSegments.lastOrNull()?.takeIf { it.isNotBlank() } else null
}
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
withContext(ioDispatcher) {
val entries = mutableMapOf<String, ByteArray>()

View File

@@ -64,6 +64,9 @@ interface FirmwareFileHandler {
*/
suspend fun importFromUri(uri: CommonUri): FirmwareArtifact?
/** Resolve a user-visible display filename for [uri], or `null` when the provider does not expose one. */
suspend fun getDisplayName(uri: CommonUri): String?
/** Copy [source] to the platform URI [destinationUri], returning the number of bytes written. */
suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long
@@ -111,18 +114,192 @@ interface FirmwareFileHandler {
* Check whether [filename] is a valid firmware binary for [target] with the expected [fileExtension]. Excludes
* non-firmware binaries that share the same extension (e.g. `littlefs-*`, `bleota*`).
*/
@Suppress("ComplexCondition")
@Suppress("ComplexCondition") // excluded-binary + target/extension guards collapsed to one early-out
internal fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
if (
filename.startsWith("littlefs-") ||
filename.startsWith("bleota") ||
filename.startsWith("mt-") ||
filename.contains(".factory.")
filename.contains(".factory.") ||
target.isBlank() ||
!filename.endsWith(fileExtension)
) {
return false
}
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_.].*")
return filename.endsWith(fileExtension) &&
filename.contains(target) &&
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
val targetToken = Regex.escape(target)
val extensionToken = Regex.escape(fileExtension)
val targetPattern = Regex("(^|.*[\\-_])$targetToken(([\\-_.](v?\\d|firmware)).*$extensionToken$|$extensionToken$)")
return targetPattern.matches(filename)
}
data class PendingLocalFirmwareFile(
val uri: CommonUri,
val fileName: String,
val deviceName: String,
val platformioTarget: String,
val updateMethod: FirmwareUpdateMethod,
val address: String,
)
/** pioEnv-aware target: falls back to [DeviceHardware.hwModelSlug] when [DeviceHardware.platformioTarget] is unset. */
internal val DeviceHardware.effectiveTarget: String
get() = platformioTarget.ifEmpty { hwModelSlug }
internal fun validatePendingLocalFirmwareFile(
pendingFile: PendingLocalFirmwareFile,
currentState: FirmwareUpdateState.Ready,
): LocalFirmwareFileValidation {
val currentTarget = currentState.deviceHardware.effectiveTarget
return when {
// The user selected the file under a different connection (method or address) than the
// one active now — the file may still match the target, but the confirmation context is stale.
currentState.updateMethod != pendingFile.updateMethod || currentState.address != pendingFile.address ->
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.ConfirmationContextChanged)
currentTarget != pendingFile.platformioTarget ->
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.TargetMismatch)
// Context matches — revalidate the filename against the current method before flashing.
else ->
validateLocalFirmwareFileName(pendingFile.fileName, currentState.deviceHardware, currentState.updateMethod)
}
}
@Suppress("ReturnCount") // two guard-clause early returns + trailing when; refactor would harm clarity
internal fun preferredLocalFirmwareArchiveFilenames(
archiveFileName: String,
hardware: DeviceHardware,
updateMethod: FirmwareUpdateMethod,
): List<String> {
val target = hardware.effectiveTarget.takeIf { it.isNotBlank() } ?: return emptyList()
val version = extractFirmwareVersion(archiveFileName) ?: return emptyList()
return when (updateMethod) {
FirmwareUpdateMethod.Ble ->
if (hardware.isEsp32Arc) {
listOf("firmware-$target-$version-update.bin", "firmware-$target-$version.bin")
} else {
listOf("firmware-$target-$version-ota.zip")
}
FirmwareUpdateMethod.Wifi -> listOf("firmware-$target-$version-update.bin", "firmware-$target-$version.bin")
FirmwareUpdateMethod.Usb -> listOf("firmware-$target-$version.uf2")
FirmwareUpdateMethod.Unknown -> emptyList()
}
}
private fun extractFirmwareVersion(fileName: String): String? {
val baseName = fileName.substringAfterLast('/').substringAfterLast('\\')
return FIRMWARE_VERSION_PATTERN.find(baseName)?.groupValues?.getOrNull(1)
}
private val FIRMWARE_VERSION_PATTERN = Regex("""(?:^|[-_])v?(\d+(?:\.[0-9A-Za-z]+)+)(?=(?:[-_][A-Za-z]+)?\.[^.]+$)""")
internal sealed interface LocalFirmwareFileValidation {
data object Valid : LocalFirmwareFileValidation
data class Invalid(val reason: LocalFirmwareFileValidationReason) : LocalFirmwareFileValidation
}
internal enum class LocalFirmwareFileValidationReason {
MissingTarget,
MissingArchiveFirmware,
RequiresOtaZip,
RequiresBin,
RequiresUf2,
TargetMismatch,
ConfirmationContextChanged,
UnsupportedUpdateMethod,
}
internal fun validateLocalFirmwareFileName(
fileName: String,
hardware: DeviceHardware,
updateMethod: FirmwareUpdateMethod,
): LocalFirmwareFileValidation {
val normalizedFileName = fileName.substringAfterLast('/').substringAfterLast('\\').lowercase()
val target = hardware.effectiveTarget.lowercase()
if (target.isBlank()) {
return LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.MissingTarget)
}
return when (updateMethod) {
FirmwareUpdateMethod.Ble ->
if (hardware.isEsp32Arc) {
validateEsp32LocalFirmware(normalizedFileName, target)
} else {
validateNrf52LocalFirmware(normalizedFileName, target)
}
FirmwareUpdateMethod.Wifi -> validateEsp32LocalFirmware(normalizedFileName, target)
FirmwareUpdateMethod.Usb -> validateUsbLocalFirmware(normalizedFileName, target)
FirmwareUpdateMethod.Unknown ->
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.UnsupportedUpdateMethod)
}
}
/**
* Returns the firmware payload file extension this platform's update handler expects to receive.
*
* nRF52 BLE consumes the Nordic DFU `.zip` package directly via [SecureDfuHandler]; ESP32 (BLE or WiFi) streams a raw
* `.bin`; USB copies a `.uf2` to the mass-storage drive. Returns `null` for [FirmwareUpdateMethod.Unknown], which does
* not support local files.
*/
internal fun localFirmwarePayloadExtension(hardware: DeviceHardware, updateMethod: FirmwareUpdateMethod): String? =
when (updateMethod) {
FirmwareUpdateMethod.Ble -> if (hardware.isEsp32Arc) ".bin" else ".zip"
FirmwareUpdateMethod.Wifi -> ".bin"
FirmwareUpdateMethod.Usb -> ".uf2"
FirmwareUpdateMethod.Unknown -> null
}
private fun validateNrf52LocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
validateSuffixedLocalFirmware(
fileName = fileName,
target = target,
requiredSuffix = "-ota.zip",
fileExtension = ".zip",
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresOtaZip,
)
private fun validateEsp32LocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
validateSuffixedLocalFirmware(
fileName = fileName,
target = target,
requiredSuffix = ".bin",
fileExtension = ".bin",
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresBin,
)
private fun validateUsbLocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
validateSuffixedLocalFirmware(
fileName = fileName,
target = target,
requiredSuffix = ".uf2",
fileExtension = ".uf2",
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresUf2,
)
private fun validateSuffixedLocalFirmware(
fileName: String,
target: String,
requiredSuffix: String,
fileExtension: String,
missingSuffixReason: LocalFirmwareFileValidationReason,
): LocalFirmwareFileValidation {
if (!fileName.endsWith(requiredSuffix)) {
return LocalFirmwareFileValidation.Invalid(missingSuffixReason)
}
return validateTargetMatch(fileName, target, fileExtension)
}
private fun validateTargetMatch(fileName: String, target: String, fileExtension: String): LocalFirmwareFileValidation =
if (isValidFirmwareFile(fileName, target, fileExtension)) {
LocalFirmwareFileValidation.Valid
} else {
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.TargetMismatch)
}

View File

@@ -23,6 +23,8 @@ data class FirmwareUpdateActions(
val onStartUpdate: () -> Unit,
val onPickFile: () -> Unit,
val onSaveFile: (String) -> Unit,
val onConfirmLocalFile: () -> Unit,
val onDismissLocalFile: () -> Unit,
val onRetry: () -> Unit,
val onCancel: () -> Unit,
val onDone: () -> Unit,

View File

@@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -68,6 +69,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -97,6 +99,9 @@ import org.meshtastic.core.resources.firmware_recovery_explanation
import org.meshtastic.core.resources.firmware_update_almost_there
import org.meshtastic.core.resources.firmware_update_alpha
import org.meshtastic.core.resources.firmware_update_checking
import org.meshtastic.core.resources.firmware_update_confirm_file_button
import org.meshtastic.core.resources.firmware_update_confirm_file_message
import org.meshtastic.core.resources.firmware_update_confirm_file_title
import org.meshtastic.core.resources.firmware_update_currently_installed
import org.meshtastic.core.resources.firmware_update_device
import org.meshtastic.core.resources.firmware_update_disclaimer_chirpy_says
@@ -171,11 +176,12 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
val deviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle()
val currentVersion by viewModel.currentFirmwareVersion.collectAsStateWithLifecycle()
val selectedRelease by viewModel.selectedRelease.collectAsStateWithLifecycle()
val pendingLocalFirmwareFile by viewModel.pendingLocalFirmwareFile.collectAsStateWithLifecycle()
var showExitConfirmation by remember { mutableStateOf(false) }
val filePickerLauncher = rememberOpenFileLauncher { uri: CommonUri? ->
uri?.let { viewModel.startUpdateFromFile(it) }
uri?.let { viewModel.prepareLocalFirmwareFile(it) }
}
val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) }
@@ -191,6 +197,8 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
}
},
onSaveFile = { fileName -> saveFileLauncher(fileName, "application/octet-stream") },
onConfirmLocalFile = viewModel::confirmLocalFirmwareFile,
onDismissLocalFile = viewModel::dismissLocalFirmwareFile,
onRetry = viewModel::checkForUpdates,
onCancel = { showExitConfirmation = true },
onDone = { onNavigateUp() },
@@ -222,6 +230,14 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
)
}
pendingLocalFirmwareFile?.let { pendingFile ->
LocalFirmwareFileConfirmationDialog(
pendingFile = pendingFile,
onConfirm = actions.onConfirmLocalFile,
onDismiss = actions.onDismissLocalFile,
)
}
FirmwareUpdateScaffold(
modifier = modifier,
onNavigateUp = onNavigateUp,
@@ -234,6 +250,49 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
)
}
@Composable
private fun LocalFirmwareFileConfirmationDialog(
pendingFile: PendingLocalFirmwareFile,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.firmware_update_confirm_file_title),
confirmText = stringResource(Res.string.firmware_update_confirm_file_button),
onConfirm = onConfirm,
dismissText = stringResource(Res.string.cancel),
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(Res.string.firmware_update_confirm_file_message),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
SelectionContainer {
Text(
text = pendingFile.fileName,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
}
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(Res.string.firmware_update_device, pendingFile.deviceName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(Res.string.firmware_update_target, pendingFile.platformioTarget),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FirmwareUpdateScaffold(

View File

@@ -61,18 +61,27 @@ import org.meshtastic.core.repository.isTcp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.firmware_recovery_ble_failed
import org.meshtastic.core.resources.firmware_update_archive_missing_target
import org.meshtastic.core.resources.firmware_update_battery_low
import org.meshtastic.core.resources.firmware_update_context_changed
import org.meshtastic.core.resources.firmware_update_copying
import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.resources.firmware_update_failed
import org.meshtastic.core.resources.firmware_update_filename_unavailable
import org.meshtastic.core.resources.firmware_update_flashing
import org.meshtastic.core.resources.firmware_update_invalid_local_file_detail
import org.meshtastic.core.resources.firmware_update_method_ble
import org.meshtastic.core.resources.firmware_update_method_usb
import org.meshtastic.core.resources.firmware_update_method_wifi
import org.meshtastic.core.resources.firmware_update_missing_target
import org.meshtastic.core.resources.firmware_update_no_device
import org.meshtastic.core.resources.firmware_update_node_info_missing
import org.meshtastic.core.resources.firmware_update_requires_bin
import org.meshtastic.core.resources.firmware_update_requires_ota_zip
import org.meshtastic.core.resources.firmware_update_requires_uf2
import org.meshtastic.core.resources.firmware_update_unknown_error
import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_unsupported_update_method
import org.meshtastic.core.resources.unknown
private const val DEVICE_DETACH_TIMEOUT = 30_000L
@@ -87,7 +96,7 @@ private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
* ViewModel driving the firmware update screen. Coordinates release checking, file retrieval, transport-specific update
* execution, and post-update device verification.
*/
@Suppress("LongParameterList", "TooManyFunctions")
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass")
@KoinViewModel
class FirmwareUpdateViewModel(
private val firmwareReleaseRepository: FirmwareReleaseRepository,
@@ -120,8 +129,13 @@ class FirmwareUpdateViewModel(
private val _currentFirmwareVersion = MutableStateFlow<String?>(null)
val currentFirmwareVersion = _currentFirmwareVersion.asStateFlow()
private val _pendingLocalFirmwareFile = MutableStateFlow<PendingLocalFirmwareFile?>(null)
val pendingLocalFirmwareFile: StateFlow<PendingLocalFirmwareFile?> = _pendingLocalFirmwareFile.asStateFlow()
private var updateJob: Job? = null
private var prepareJob: Job? = null
private var tempFirmwareFile: FirmwareArtifact? = null
private var pendingLocalFirmwareArtifact: FirmwareArtifact? = null
private var originalDeviceAddress: String? = null
/** Set when [checkForUpdates] enters recovery mode (disconnected + a saved record); consumed by [startUpdate]. */
@@ -138,11 +152,22 @@ class FirmwareUpdateViewModel(
@OptIn(DelicateCoroutinesApi::class)
override fun onCleared() {
super.onCleared()
prepareJob?.cancel()
prepareJob = null
// viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
// application-wide scope (SupervisorJob + ioDispatcher). ATOMIC start + NonCancellable
// context keeps cleanup running even if something tries to cancel it mid-flight.
val pendingArtifact = pendingLocalFirmwareArtifact
pendingLocalFirmwareArtifact = null
applicationScope.launch(start = CoroutineStart.ATOMIC) {
withContext(NonCancellable) { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) }
withContext(NonCancellable) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
if (pendingArtifact != null && pendingArtifact != tempFirmwareFile) {
cleanupTemporaryFiles(fileHandler, pendingArtifact)
}
safeCatching { fileHandler.cleanupAllTemporaryFiles() }
.onFailure { Logger.w(it) { "Failed to cleanup remaining temp files" } }
}
}
}
@@ -153,6 +178,7 @@ class FirmwareUpdateViewModel(
fun cancelUpdate() {
updateJob?.cancel()
clearPendingLocalFirmwareFile()
_state.value = FirmwareUpdateState.Idle
checkForUpdates()
}
@@ -160,6 +186,7 @@ class FirmwareUpdateViewModel(
@Suppress("LongMethod")
fun checkForUpdates() {
updateJob?.cancel()
clearPendingLocalFirmwareFile()
updateJob =
viewModelScope.launch {
_state.value = FirmwareUpdateState.Checking
@@ -447,10 +474,226 @@ class FirmwareUpdateViewModel(
}
}
fun startUpdateFromFile(uri: CommonUri) {
fun prepareLocalFirmwareFile(uri: CommonUri) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
clearPendingLocalFirmwareFile()
prepareJob =
viewModelScope.launch {
try {
val fileName =
safeCatching { fileHandler.getDisplayName(uri)?.takeIf { it.isNotBlank() } }
.getOrElse { e ->
Logger.w(e) { "Failed to resolve local firmware filename" }
null
}
// State may have changed during the suspend call (e.g. cancelUpdate, checkForUpdates).
// Do not write errors or reopen the confirmation dialog for a stale selection.
when {
_state.value != currentState -> Unit
fileName == null ->
_state.value =
FirmwareUpdateState.Error(
UiText.Resource(Res.string.firmware_update_filename_unavailable),
)
else -> {
val resolution = resolveLocalFirmwareFile(uri, fileName, currentState)
if (_state.value != currentState) {
cleanupResolvedLocalFirmwareFile(resolution)
} else {
applyLocalFirmwareResolution(resolution, currentState)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) { "Error preparing local firmware file" }
if (_state.value == currentState) {
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
}
}
}
}
fun confirmLocalFirmwareFile() {
val pendingSelection = _pendingLocalFirmwareFile.value ?: return
val pendingArtifact = pendingLocalFirmwareArtifact
_pendingLocalFirmwareFile.value = null
pendingLocalFirmwareArtifact = null
val currentState = _state.value as? FirmwareUpdateState.Ready
if (currentState != null) {
val validation = validatePendingLocalFirmwareFile(pendingSelection, currentState)
if (validation is LocalFirmwareFileValidation.Invalid) {
_state.value =
FirmwareUpdateState.Error(
localFirmwareValidationError(validation.reason, pendingSelection.fileName, currentState),
)
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
} else {
startUpdateFromFile(pendingSelection.uri, pendingArtifact)
}
} else {
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
}
}
fun dismissLocalFirmwareFile() {
clearPendingLocalFirmwareFile()
}
private suspend fun resolveLocalFirmwareFile(
uri: CommonUri,
fileName: String,
state: FirmwareUpdateState.Ready,
): LocalFirmwareResolution {
val validation = validateLocalFirmwareFileName(fileName, state.deviceHardware, state.updateMethod)
return when (validation) {
LocalFirmwareFileValidation.Valid -> LocalFirmwareResolution.Resolved(uri = uri, fileName = fileName)
is LocalFirmwareFileValidation.Invalid ->
if (shouldTryLocalFirmwareBundle(fileName, validation.reason)) {
resolveLocalFirmwareBundle(uri, fileName, state, validation.reason)
} else {
LocalFirmwareResolution.Invalid(reason = validation.reason, fileName = fileName)
}
}
}
private suspend fun resolveLocalFirmwareBundle(
uri: CommonUri,
fileName: String,
state: FirmwareUpdateState.Ready,
fallbackReason: LocalFirmwareFileValidationReason,
): LocalFirmwareResolution {
val payloadExtension = localFirmwarePayloadExtension(state.deviceHardware, state.updateMethod)
return if (payloadExtension == null) {
LocalFirmwareResolution.Invalid(reason = fallbackReason, fileName = fileName)
} else {
val extractingState =
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_extracting)))
_state.value = extractingState
try {
val extractedArtifact = extractLocalFirmwareArchive(uri, fileName, state, payloadExtension)
if (extractedArtifact == null) {
LocalFirmwareResolution.Invalid(
reason = LocalFirmwareFileValidationReason.MissingArchiveFirmware,
fileName = fileName,
)
} else {
validateExtractedLocalFirmware(extractedArtifact, fileName, state)
}
} finally {
if (_state.value == extractingState) {
_state.value = state
}
}
}
}
private suspend fun extractLocalFirmwareArchive(
uri: CommonUri,
fileName: String,
state: FirmwareUpdateState.Ready,
payloadExtension: String,
): FirmwareArtifact? {
val preferredFilenames =
preferredLocalFirmwareArchiveFilenames(fileName, state.deviceHardware, state.updateMethod)
for (preferredFilename in preferredFilenames) {
safeCatching { fileHandler.extractFirmware(uri, state.deviceHardware, payloadExtension, preferredFilename) }
.onFailure { e -> Logger.w(e) { "Failed to extract preferred local firmware $preferredFilename" } }
.getOrNull()
?.let {
return it
}
}
return safeCatching { fileHandler.extractFirmware(uri, state.deviceHardware, payloadExtension) }
.getOrElse { e ->
Logger.w(e) { "Failed to extract local firmware archive" }
null
}
}
private fun validateExtractedLocalFirmware(
extractedArtifact: FirmwareArtifact,
fallbackFileName: String,
state: FirmwareUpdateState.Ready,
): LocalFirmwareResolution {
val extractedFileName = extractedArtifact.fileName
val extractedValidation =
extractedFileName?.let { validateLocalFirmwareFileName(it, state.deviceHardware, state.updateMethod) }
?: LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.MissingArchiveFirmware)
return if (extractedValidation == LocalFirmwareFileValidation.Valid && extractedFileName != null) {
LocalFirmwareResolution.Resolved(
uri = extractedArtifact.uri,
fileName = extractedFileName,
temporaryArtifact = extractedArtifact.takeIf { it.isTemporary },
)
} else {
cleanupPendingLocalFirmwareArtifact(extractedArtifact)
LocalFirmwareResolution.Invalid(
reason = (extractedValidation as LocalFirmwareFileValidation.Invalid).reason,
fileName = extractedFileName ?: fallbackFileName,
)
}
}
private fun applyLocalFirmwareResolution(resolution: LocalFirmwareResolution, state: FirmwareUpdateState.Ready) {
when (resolution) {
is LocalFirmwareResolution.Invalid ->
_state.value =
FirmwareUpdateState.Error(
localFirmwareValidationError(resolution.reason, resolution.fileName, state),
)
is LocalFirmwareResolution.Resolved -> {
pendingLocalFirmwareArtifact = resolution.temporaryArtifact
_pendingLocalFirmwareFile.value =
PendingLocalFirmwareFile(
uri = resolution.uri,
fileName = resolution.fileName,
deviceName = state.deviceHardware.displayName,
platformioTarget = state.deviceHardware.effectiveTarget,
updateMethod = state.updateMethod,
address = state.address,
)
}
}
}
private fun cleanupResolvedLocalFirmwareFile(resolution: LocalFirmwareResolution) {
if (resolution is LocalFirmwareResolution.Resolved) {
cleanupPendingLocalFirmwareArtifact(resolution.temporaryArtifact)
}
}
private fun clearPendingLocalFirmwareFile() {
prepareJob?.cancel()
prepareJob = null
val artifact = pendingLocalFirmwareArtifact
_pendingLocalFirmwareFile.value = null
pendingLocalFirmwareArtifact = null
cleanupPendingLocalFirmwareArtifact(artifact)
}
private fun cleanupPendingLocalFirmwareArtifact(artifact: FirmwareArtifact?) {
artifact
?.takeIf { it.isTemporary }
?.let {
viewModelScope.launch {
safeCatching { fileHandler.deleteFile(it) }
.onFailure { e -> Logger.w(e) { "Failed to cleanup pending local firmware file" } }
}
}
}
private fun startUpdateFromFile(uri: CommonUri, pendingArtifact: FirmwareArtifact? = null) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) {
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_no_device))
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
return
}
originalDeviceAddress = radioPrefs.devAddr.value
@@ -459,25 +702,20 @@ class FirmwareUpdateViewModel(
updateJob =
viewModelScope.launch {
try {
_state.value =
FirmwareUpdateState.Processing(
ProgressState(UiText.Resource(Res.string.firmware_update_extracting)),
)
val extension = if (currentState.updateMethod is FirmwareUpdateMethod.Ble) ".zip" else ".uf2"
val extractedFile = fileHandler.extractFirmware(uri, currentState.deviceHardware, extension)
tempFirmwareFile = extractedFile
val firmwareUri = extractedFile?.uri ?: uri
val updateArtifact =
firmwareUpdateManager.startUpdate(
release = FirmwareRelease(id = LOCAL_RELEASE_ID, zipUrl = "", releaseNotes = ""),
hardware = currentState.deviceHardware,
address = currentState.address,
updateState = { _state.value = it },
firmwareUri = firmwareUri,
firmwareUri = uri,
)
tempFirmwareFile = updateArtifact ?: extractedFile
tempFirmwareFile = updateArtifact?.takeIf { it.isTemporary } ?: pendingArtifact
// If the handler created its own temp copy (e.g. ESP32 importFromUri),
// clean up the extracted bundle artifact to prevent a leak.
if (pendingArtifact != null && pendingArtifact != tempFirmwareFile) {
cleanupTemporaryFiles(fileHandler, pendingArtifact)
}
when (val finalState = _state.value) {
is FirmwareUpdateState.Success ->
@@ -501,10 +739,51 @@ class FirmwareUpdateViewModel(
} catch (e: Exception) {
Logger.e(e) { "Error starting update from file" }
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile ?: pendingArtifact)
}
}
}
private fun localFirmwareValidationError(
reason: LocalFirmwareFileValidationReason,
fileName: String,
state: FirmwareUpdateState.Ready,
): UiText = when (reason) {
LocalFirmwareFileValidationReason.MissingArchiveFirmware ->
UiText.Resource(
Res.string.firmware_update_archive_missing_target,
fileName,
state.deviceHardware.displayName,
state.deviceHardware.effectiveTarget,
)
LocalFirmwareFileValidationReason.MissingTarget ->
UiText.Resource(Res.string.firmware_update_missing_target)
LocalFirmwareFileValidationReason.UnsupportedUpdateMethod ->
UiText.Resource(Res.string.firmware_update_unsupported_update_method)
LocalFirmwareFileValidationReason.RequiresOtaZip ->
UiText.Resource(Res.string.firmware_update_requires_ota_zip, state.deviceHardware.displayName)
LocalFirmwareFileValidationReason.RequiresBin ->
UiText.Resource(Res.string.firmware_update_requires_bin, state.deviceHardware.displayName)
LocalFirmwareFileValidationReason.RequiresUf2 ->
UiText.Resource(Res.string.firmware_update_requires_uf2, state.deviceHardware.displayName)
LocalFirmwareFileValidationReason.ConfirmationContextChanged ->
UiText.Resource(Res.string.firmware_update_context_changed)
LocalFirmwareFileValidationReason.TargetMismatch ->
UiText.Resource(
Res.string.firmware_update_invalid_local_file_detail,
fileName,
state.deviceHardware.displayName,
state.deviceHardware.effectiveTarget,
)
}
fun dismissBootloaderWarningForCurrentDevice() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
viewModelScope.launch {
@@ -591,14 +870,36 @@ class FirmwareUpdateViewModel(
}
}
private sealed interface LocalFirmwareResolution {
data class Resolved(val uri: CommonUri, val fileName: String, val temporaryArtifact: FirmwareArtifact? = null) :
LocalFirmwareResolution
data class Invalid(val reason: LocalFirmwareFileValidationReason, val fileName: String) : LocalFirmwareResolution
}
private fun shouldTryLocalFirmwareBundle(fileName: String, reason: LocalFirmwareFileValidationReason): Boolean {
val normalizedFileName = fileName.substringAfterLast('/').substringAfterLast('\\').lowercase()
return normalizedFileName.endsWith(".zip") &&
when (reason) {
LocalFirmwareFileValidationReason.RequiresOtaZip,
LocalFirmwareFileValidationReason.RequiresBin,
LocalFirmwareFileValidationReason.RequiresUf2,
-> true
LocalFirmwareFileValidationReason.MissingArchiveFirmware,
LocalFirmwareFileValidationReason.MissingTarget,
LocalFirmwareFileValidationReason.TargetMismatch,
LocalFirmwareFileValidationReason.ConfirmationContextChanged,
LocalFirmwareFileValidationReason.UnsupportedUpdateMethod,
-> false
}
}
private suspend fun cleanupTemporaryFiles(
fileHandler: FirmwareFileHandler,
tempFirmwareFile: FirmwareArtifact?,
): FirmwareArtifact? {
safeCatching {
tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) }
fileHandler.cleanupAllTemporaryFiles()
}
safeCatching { tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) } }
.onFailure { e -> Logger.w(e) { "Failed to cleanup temp files" } }
return null
}

View File

@@ -472,6 +472,8 @@ abstract class CommonFirmwareRetrieverTest {
override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = null
override suspend fun getDisplayName(uri: CommonUri): String? = uri.pathSegments.lastOrNull()
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> = emptyMap()
override suspend fun deleteFile(file: FirmwareArtifact) {}

View File

@@ -16,8 +16,12 @@
*/
package org.meshtastic.feature.firmware
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.DeviceHardware
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
@@ -44,13 +48,13 @@ class IsValidFirmwareFileTest {
}
@Test
fun `filename starting with target-dash matches`() {
assertTrue(isValidFirmwareFile("heltec-v3-firmware-2.7.17.bin", "heltec-v3", ".bin"))
fun `filename starting with target and version matches`() {
assertTrue(isValidFirmwareFile("heltec-v3-2.7.17.bin", "heltec-v3", ".bin"))
}
@Test
fun `filename starting with target-dot matches`() {
assertTrue(isValidFirmwareFile("heltec-v3.firmware.bin", "heltec-v3", ".bin"))
fun `filename starting with target and extension matches`() {
assertTrue(isValidFirmwareFile("heltec-v3.bin", "heltec-v3", ".bin"))
}
@Test
@@ -103,6 +107,33 @@ class IsValidFirmwareFileTest {
assertFalse(isValidFirmwareFile("firmware-pico2w-2.7.17.uf2", "pico", ".uf2"))
}
@Test
fun `rejects target prefix collisions`() {
assertFalse(isValidFirmwareFile("firmware-tbeam-s3-core-2.8.0.bin", "tbeam", ".bin"))
assertFalse(isValidFirmwareFile("firmware-t-echo-lite-2.8.0.bin", "t-echo", ".bin"))
assertFalse(isValidFirmwareFile("firmware-t-deck-pro-2.8.0.bin", "t-deck", ".bin"))
assertFalse(isValidFirmwareFile("firmware-nano-g1-explorer-2.8.0.bin", "nano-g1", ".bin"))
}
@Test
fun `accepts full colliding target names`() {
assertTrue(isValidFirmwareFile("firmware-tbeam-s3-core-2.8.0.bin", "tbeam-s3-core", ".bin"))
assertTrue(isValidFirmwareFile("firmware-t-echo-lite-2.8.0.bin", "t-echo-lite", ".bin"))
assertTrue(isValidFirmwareFile("firmware-t-deck-pro-2.8.0.bin", "t-deck-pro", ".bin"))
assertTrue(isValidFirmwareFile("firmware-nano-g1-explorer-2.8.0.bin", "nano-g1-explorer", ".bin"))
}
@Test
fun `accepts older local firmware filename shapes`() {
// Older naming where "firmware" appears as a segment after the target.
assertTrue(isValidFirmwareFile("heltec-v3-firmware-2.7.17.bin", "heltec-v3", ".bin"))
assertTrue(isValidFirmwareFile("heltec-v3.firmware.bin", "heltec-v3", ".bin"))
// Standard release names still work.
assertTrue(isValidFirmwareFile("firmware-heltec-v3-2.7.17.bin", "heltec-v3", ".bin"))
assertTrue(isValidFirmwareFile("heltec-v3-2.7.17.bin", "heltec-v3", ".bin"))
assertTrue(isValidFirmwareFile("heltec-v3.bin", "heltec-v3", ".bin"))
}
// ── Edge cases ──────────────────────────────────────────────────────────
@Test
@@ -116,4 +147,291 @@ class IsValidFirmwareFileTest {
// the function still requires the extension
assertFalse(isValidFirmwareFile("firmware.bin", "", ".uf2"))
}
@Test
fun `nrf BLE local firmware accepts target matching ota zip`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
assertEquals(
LocalFirmwareFileValidation.Valid,
validateLocalFirmwareFileName("firmware-rak4631-2.8.0-ota.zip", hardware, FirmwareUpdateMethod.Ble),
)
}
@Test
fun `nrf BLE local firmware rejects zip without ota suffix`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
assertInvalidReason(
LocalFirmwareFileValidationReason.RequiresOtaZip,
validateLocalFirmwareFileName("firmware-rak4631-2.8.0.zip", hardware, FirmwareUpdateMethod.Ble),
)
}
@Test
fun `nrf BLE local firmware rejects generic architecture zip`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
assertInvalidReason(
LocalFirmwareFileValidationReason.RequiresOtaZip,
validateLocalFirmwareFileName("firmware-nrf52840-2.8.0.zip", hardware, FirmwareUpdateMethod.Ble),
)
}
@Test
fun `nrf BLE local firmware rejects wrong target ota zip`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
assertInvalidReason(
LocalFirmwareFileValidationReason.TargetMismatch,
validateLocalFirmwareFileName("firmware-tbeam-2.8.0-ota.zip", hardware, FirmwareUpdateMethod.Ble),
)
}
@Test
fun `esp32 local firmware rejects ota helper binary`() {
val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "esp32")
assertInvalidReason(
LocalFirmwareFileValidationReason.TargetMismatch,
validateLocalFirmwareFileName("mt-esp32-ota.bin", hardware, FirmwareUpdateMethod.Wifi),
)
}
@Test
fun `esp32 local firmware rejects bleota helper binary`() {
val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "c3")
assertInvalidReason(
LocalFirmwareFileValidationReason.TargetMismatch,
validateLocalFirmwareFileName("bleota-c3.bin", hardware, FirmwareUpdateMethod.Ble),
)
}
@Test
fun `esp32 local firmware rejects factory binary`() {
val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "heltec-v3")
assertInvalidReason(
LocalFirmwareFileValidationReason.TargetMismatch,
validateLocalFirmwareFileName("firmware-heltec-v3-2.8.0.factory.bin", hardware, FirmwareUpdateMethod.Wifi),
)
}
@Test
fun `esp32 local firmware accepts target matching app binary`() {
val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "heltec-v3")
assertEquals(
LocalFirmwareFileValidation.Valid,
validateLocalFirmwareFileName("firmware-heltec-v3-2.8.0.bin", hardware, FirmwareUpdateMethod.Wifi),
)
}
@Test
fun `usb local firmware accepts matching uf2 and rejects wrong target`() {
val hardware = DeviceHardware(architecture = "rp2040", platformioTarget = "pico")
assertEquals(
LocalFirmwareFileValidation.Valid,
validateLocalFirmwareFileName("firmware-pico-2.8.0.uf2", hardware, FirmwareUpdateMethod.Usb),
)
assertInvalidReason(
LocalFirmwareFileValidationReason.TargetMismatch,
validateLocalFirmwareFileName("firmware-pico2w-2.8.0.uf2", hardware, FirmwareUpdateMethod.Usb),
)
}
@Test
fun `local firmware payload extension follows update method and architecture`() {
val nrfHardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
val espHardware = DeviceHardware(architecture = "esp32", platformioTarget = "heltec-v3")
val usbHardware = DeviceHardware(architecture = "rp2040", platformioTarget = "pico")
assertEquals(".zip", localFirmwarePayloadExtension(nrfHardware, FirmwareUpdateMethod.Ble))
assertEquals(".bin", localFirmwarePayloadExtension(espHardware, FirmwareUpdateMethod.Ble))
assertEquals(".bin", localFirmwarePayloadExtension(espHardware, FirmwareUpdateMethod.Wifi))
assertEquals(".uf2", localFirmwarePayloadExtension(usbHardware, FirmwareUpdateMethod.Usb))
assertNull(localFirmwarePayloadExtension(nrfHardware, FirmwareUpdateMethod.Unknown))
}
@Test
fun `pending local firmware validates unchanged confirmation context`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
val readyState = readyState(hardware = hardware, updateMethod = FirmwareUpdateMethod.Ble)
val pendingFile =
pendingFile(
fileName = "firmware-rak4631-2.8.0-ota.zip",
platformioTarget = "rak4631",
updateMethod = FirmwareUpdateMethod.Ble,
)
assertEquals(LocalFirmwareFileValidation.Valid, validatePendingLocalFirmwareFile(pendingFile, readyState))
}
@Test
fun `pending local firmware rejects changed target before confirmation`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "tbeam")
val readyState = readyState(hardware = hardware, updateMethod = FirmwareUpdateMethod.Ble)
val pendingFile =
pendingFile(
fileName = "firmware-rak4631-2.8.0-ota.zip",
platformioTarget = "rak4631",
updateMethod = FirmwareUpdateMethod.Ble,
)
assertInvalidReason(
LocalFirmwareFileValidationReason.TargetMismatch,
validatePendingLocalFirmwareFile(pendingFile, readyState),
)
}
@Test
fun `pending local firmware rejects changed update method before confirmation`() {
val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "tbeam")
val readyState = readyState(hardware = hardware, updateMethod = FirmwareUpdateMethod.Wifi)
val pendingFile =
pendingFile(
fileName = "firmware-tbeam-2.8.0.bin",
platformioTarget = "tbeam",
updateMethod = FirmwareUpdateMethod.Ble,
)
assertInvalidReason(
LocalFirmwareFileValidationReason.ConfirmationContextChanged,
validatePendingLocalFirmwareFile(pendingFile, readyState),
)
}
@Test
fun `pending local firmware rejects changed address before confirmation`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
val readyState =
readyState(hardware = hardware, updateMethod = FirmwareUpdateMethod.Ble, address = "11:22:33:44:55:77")
val pendingFile =
pendingFile(
fileName = "firmware-rak4631-2.8.0-ota.zip",
platformioTarget = "rak4631",
updateMethod = FirmwareUpdateMethod.Ble,
address = "11:22:33:44:55:66",
)
assertInvalidReason(
LocalFirmwareFileValidationReason.ConfirmationContextChanged,
validatePendingLocalFirmwareFile(pendingFile, readyState),
)
}
@Test
fun `pending local firmware prioritizes changed context over target mismatch`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "tbeam")
val readyState =
readyState(hardware = hardware, updateMethod = FirmwareUpdateMethod.Ble, address = "11:22:33:44:55:77")
val pendingFile =
pendingFile(
fileName = "firmware-rak4631-2.8.0-ota.zip",
platformioTarget = "rak4631",
updateMethod = FirmwareUpdateMethod.Ble,
address = "11:22:33:44:55:66",
)
assertInvalidReason(
LocalFirmwareFileValidationReason.ConfirmationContextChanged,
validatePendingLocalFirmwareFile(pendingFile, readyState),
)
}
@Test
fun `preferred local firmware archive filenames follow retriever order`() {
val esp32Hardware = DeviceHardware(architecture = "esp32", platformioTarget = "tbeam")
val nrfHardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
val usbHardware = DeviceHardware(architecture = "rp2040", platformioTarget = "pico")
assertEquals(
listOf("firmware-tbeam-2.7.15-update.bin", "firmware-tbeam-2.7.15.bin"),
preferredLocalFirmwareArchiveFilenames(
"firmware-esp32-2.7.15.zip",
esp32Hardware,
FirmwareUpdateMethod.Wifi,
),
)
assertEquals(
listOf("firmware-rak4631-2.8.0-ota.zip"),
preferredLocalFirmwareArchiveFilenames(
"firmware-nrf52840-2.8.0.zip",
nrfHardware,
FirmwareUpdateMethod.Ble,
),
)
assertEquals(
listOf("firmware-pico-2.8.0.uf2"),
preferredLocalFirmwareArchiveFilenames("firmware-rp2040-2.8.0.zip", usbHardware, FirmwareUpdateMethod.Usb),
)
}
@Test
fun `validate rejects blank target with MissingTarget`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "", hwModelSlug = "")
assertInvalidReason(
LocalFirmwareFileValidationReason.MissingTarget,
validateLocalFirmwareFileName("firmware-rak4631-2.8.0-ota.zip", hardware, FirmwareUpdateMethod.Ble),
)
}
@Test
fun `validate rejects Unknown update method with UnsupportedUpdateMethod`() {
val hardware = DeviceHardware(architecture = "nrf52", platformioTarget = "rak4631")
assertInvalidReason(
LocalFirmwareFileValidationReason.UnsupportedUpdateMethod,
validateLocalFirmwareFileName("firmware-rak4631-2.8.0-ota.zip", hardware, FirmwareUpdateMethod.Unknown),
)
}
@Test
fun `esp32 rejects wrong extension with RequiresBin`() {
val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "heltec-v3")
assertInvalidReason(
LocalFirmwareFileValidationReason.RequiresBin,
validateLocalFirmwareFileName("firmware-heltec-v3-2.8.0.uf2", hardware, FirmwareUpdateMethod.Wifi),
)
}
@Test
fun `usb rejects wrong extension with RequiresUf2`() {
val hardware = DeviceHardware(architecture = "rp2040", platformioTarget = "pico")
assertInvalidReason(
LocalFirmwareFileValidationReason.RequiresUf2,
validateLocalFirmwareFileName("firmware-pico-2.8.0.bin", hardware, FirmwareUpdateMethod.Usb),
)
}
private fun readyState(
hardware: DeviceHardware,
updateMethod: FirmwareUpdateMethod,
address: String = "11:22:33:44:55:66",
) = FirmwareUpdateState.Ready(
release = null,
deviceHardware = hardware,
address = address,
showBootloaderWarning = false,
updateMethod = updateMethod,
)
private fun pendingFile(
fileName: String,
platformioTarget: String,
updateMethod: FirmwareUpdateMethod,
address: String = "11:22:33:44:55:66",
) = PendingLocalFirmwareFile(
uri = CommonUri.parse("file:///downloads/$fileName"),
fileName = fileName,
deviceName = "Test device",
platformioTarget = platformioTarget,
updateMethod = updateMethod,
address = address,
)
private fun assertInvalidReason(expected: LocalFirmwareFileValidationReason, actual: LocalFirmwareFileValidation) {
assertEquals(LocalFirmwareFileValidation.Invalid(expected), actual)
}
}

View File

@@ -168,6 +168,19 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl
dest.toFirmwareArtifact()
}
override suspend fun getDisplayName(uri: CommonUri): String? = withContext(ioDispatcher) {
val localFile = uri.toLocalFileOrNull()
localFile?.name?.takeIf { it.isNotBlank() }
?: run {
val scheme = runCatching { URI(uri.toString()).scheme }.getOrNull()
if (scheme == "file") {
uri.pathSegments.lastOrNull()?.takeIf { it.isNotBlank() }
} else {
null
}
}
}
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
withContext(ioDispatcher) {
val entries = mutableMapOf<String, ByteArray>()
@@ -201,7 +214,7 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl
fileExtension: String,
preferredFilename: String?,
): FirmwareArtifact? {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val target = hardware.effectiveTarget
if (target.isEmpty() && preferredFilename == null) return null
val targetLowerCase = target.lowercase()

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.firmware
import androidx.lifecycle.ViewModelStore
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
@@ -23,6 +24,10 @@ import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify.VerifyMode.Companion.atLeast
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +35,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.CommonUri
@@ -40,19 +46,25 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* JVM-only ViewModel tests for paths that require [CommonUri.parse] (which delegates to `java.net.URI` on JVM). Covers
* [FirmwareUpdateViewModel.saveDfuFile] and [FirmwareUpdateViewModel.startUpdateFromFile].
* [FirmwareUpdateViewModel.saveDfuFile] and [FirmwareUpdateViewModel.confirmLocalFirmwareFile].
*/
@OptIn(ExperimentalCoroutinesApi::class)
class FirmwareUpdateViewModelFileTest {
@@ -102,6 +114,8 @@ class FirmwareUpdateViewModelFileTest {
every { fileHandler.cleanupAllTemporaryFiles() } returns Unit
everySuspend { fileHandler.deleteFile(any()) } returns Unit
everySuspend { fileHandler.getDisplayName(any()) } calls { (it.args[0] as CommonUri).pathSegments.lastOrNull() }
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns null
}
@AfterTest
@@ -123,6 +137,8 @@ class FirmwareUpdateViewModelFileTest {
TestApplicationCoroutineScope(testDispatcher),
)
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
// -----------------------------------------------------------------------
// saveDfuFile()
// -----------------------------------------------------------------------
@@ -154,11 +170,11 @@ class FirmwareUpdateViewModelFileTest {
}
// -----------------------------------------------------------------------
// startUpdateFromFile()
// prepareLocalFirmwareFile() / confirmLocalFirmwareFile()
// -----------------------------------------------------------------------
@Test
fun `startUpdateFromFile with BLE and invalid address shows error`() = runTest {
fun `confirmLocalFirmwareFile with BLE and invalid address shows error`() = runTest {
// Use a BLE prefix but non-MAC address to trigger validation failure
every { radioPrefs.devAddr } returns MutableStateFlow("xnot-a-mac-address")
@@ -169,14 +185,16 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Ready>(state)
assertIs<FirmwareUpdateMethod.Ble>(state.updateMethod)
viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip"))
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
advanceUntilIdle()
viewModel.confirmLocalFirmwareFile()
advanceUntilIdle()
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
}
@Test
fun `startUpdateFromFile extracts and starts update`() = runTest {
fun `confirmLocalFirmwareFile starts update after pending selection`() = runTest {
// Serial nRF52 → USB method (no BLE address validation)
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
@@ -187,15 +205,6 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Ready>(state)
assertIs<FirmwareUpdateMethod.Usb>(state.updateMethod)
// Mock extraction
val extractedArtifact =
FirmwareArtifact(
uri = CommonUri.parse("file:///tmp/extracted-firmware.uf2"),
fileName = "extracted-firmware.uf2",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns extractedArtifact
// Mock startUpdate to transition to Success
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
.calls {
@@ -205,7 +214,13 @@ class FirmwareUpdateViewModelFileTest {
null
}
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
advanceUntilIdle()
assertEquals("firmware-tbeam-2.8.0.uf2", viewModel.pendingLocalFirmwareFile.value?.fileName)
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
viewModel.confirmLocalFirmwareFile()
advanceUntilIdle()
// Should reach Success, Verifying, or VerificationFailed (verification timeout in test)
@@ -219,27 +234,213 @@ class FirmwareUpdateViewModelFileTest {
}
@Test
fun `startUpdateFromFile handles extraction failure`() = runTest {
fun `dismissLocalFirmwareFile clears pending selection`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
viewModel = createViewModel()
advanceUntilIdle()
// Mock extraction to throw
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } calls
{
throw RuntimeException("Corrupt zip file")
}
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
advanceUntilIdle()
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/corrupt.zip"))
assertEquals("firmware-tbeam-2.8.0.uf2", viewModel.pendingLocalFirmwareFile.value?.fileName)
viewModel.dismissLocalFirmwareFile()
assertNull(viewModel.pendingLocalFirmwareFile.value)
}
@Test
fun `prepareLocalFirmwareFile shows error when archive has no matching target`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(firmwareUri("corrupt.zip"))
advanceUntilIdle()
val state = viewModel.state.value
assertIs<FirmwareUpdateState.Error>(state)
assertNull(viewModel.pendingLocalFirmwareFile.value)
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
}
@Test
fun `startUpdateFromFile passes BLE extension for BLE method`() = runTest {
fun `prepareLocalFirmwareFile extracts nrf firmware from release bundle`() = runTest {
val bundleUri = firmwareUri("firmware-nrf52840-2.8.0.zip")
val extractedArtifact =
FirmwareArtifact(
uri = firmwareUri("firmware-tbeam-2.8.0-ota.zip"),
fileName = "firmware-tbeam-2.8.0-ota.zip",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(bundleUri)
advanceUntilIdle()
val pendingFile = viewModel.pendingLocalFirmwareFile.value
assertEquals("firmware-tbeam-2.8.0-ota.zip", pendingFile?.fileName)
assertEquals(extractedArtifact.uri, pendingFile?.uri)
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
}
@Test
fun `prepareLocalFirmwareFile shows extracting progress while resolving bundle`() = runTest {
val bundleUri = firmwareUri("firmware-nrf52840-2.8.0.zip")
val extractionStarted = CompletableDeferred<Unit>()
val allowExtraction = CompletableDeferred<Unit>()
val extractedArtifact =
FirmwareArtifact(
uri = firmwareUri("firmware-tbeam-2.8.0-ota.zip"),
fileName = "firmware-tbeam-2.8.0-ota.zip",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) }
.calls {
extractionStarted.complete(Unit)
allowExtraction.await()
extractedArtifact
}
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(bundleUri)
runCurrent()
assertTrue(extractionStarted.isCompleted)
val processing = assertIs<FirmwareUpdateState.Processing>(viewModel.state.value)
val message = assertIs<UiText.Resource>(processing.progressState.message)
assertEquals(Res.string.firmware_update_extracting, message.res)
allowExtraction.complete(Unit)
advanceUntilIdle()
assertEquals("firmware-tbeam-2.8.0-ota.zip", viewModel.pendingLocalFirmwareFile.value?.fileName)
}
@Test
fun `prepareLocalFirmwareFile extracts esp32 firmware from release bundle`() = runTest {
val espHardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
Result.success(espHardware)
val bundleUri = firmwareUri("firmware-esp32-2.8.0.zip")
val extractedArtifact =
FirmwareArtifact(
uri = firmwareUri("firmware-tbeam-2.8.0.bin"),
fileName = "firmware-tbeam-2.8.0.bin",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(bundleUri)
advanceUntilIdle()
val pendingFile = viewModel.pendingLocalFirmwareFile.value
assertEquals("firmware-tbeam-2.8.0.bin", pendingFile?.fileName)
assertEquals(extractedArtifact.uri, pendingFile?.uri)
}
@Test
fun `prepareLocalFirmwareFile prefers esp32 update binary from release bundle`() = runTest {
val espHardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
Result.success(espHardware)
val requestedPreferredFilenames = mutableListOf<String?>()
val extractedArtifact =
FirmwareArtifact(
uri = firmwareUri("firmware-tbeam-2.7.15-update.bin"),
fileName = "firmware-tbeam-2.7.15-update.bin",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) }
.calls {
val preferredFilename = it.args[3] as String?
requestedPreferredFilenames += preferredFilename
if (preferredFilename == "firmware-tbeam-2.7.15-update.bin") extractedArtifact else null
}
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-esp32-2.7.15.zip"))
advanceUntilIdle()
assertEquals(listOf<String?>("firmware-tbeam-2.7.15-update.bin"), requestedPreferredFilenames)
assertEquals("firmware-tbeam-2.7.15-update.bin", viewModel.pendingLocalFirmwareFile.value?.fileName)
}
@Test
fun `prepareLocalFirmwareFile extracts usb firmware from release bundle`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
val bundleUri = firmwareUri("firmware-nrf52840-2.8.0.zip")
val extractedArtifact =
FirmwareArtifact(
uri = firmwareUri("firmware-tbeam-2.8.0.uf2"),
fileName = "firmware-tbeam-2.8.0.uf2",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(bundleUri)
advanceUntilIdle()
val pendingFile = viewModel.pendingLocalFirmwareFile.value
assertEquals("firmware-tbeam-2.8.0.uf2", pendingFile?.fileName)
assertEquals(extractedArtifact.uri, pendingFile?.uri)
}
@Test
fun `dismissLocalFirmwareFile deletes extracted bundle firmware`() = runTest {
val extractedArtifact =
FirmwareArtifact(
uri = firmwareUri("firmware-tbeam-2.8.0-ota.zip"),
fileName = "firmware-tbeam-2.8.0-ota.zip",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-nrf52840-2.8.0.zip"))
advanceUntilIdle()
viewModel.dismissLocalFirmwareFile()
advanceUntilIdle()
assertNull(viewModel.pendingLocalFirmwareFile.value)
verifySuspend { fileHandler.deleteFile(extractedArtifact) }
}
@Test
fun `checkForUpdates clears pending local firmware selection`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
advanceUntilIdle()
assertEquals("firmware-tbeam-2.8.0-ota.zip", viewModel.pendingLocalFirmwareFile.value?.fileName)
viewModel.checkForUpdates()
advanceUntilIdle()
assertNull(viewModel.pendingLocalFirmwareFile.value)
}
@Test
fun `confirmLocalFirmwareFile starts ESP32 BLE update without zip extraction`() = runTest {
// BLE with valid MAC address
every { radioPrefs.devAddr } returns MutableStateFlow("x11:22:33:44:55:66")
val espHardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
@@ -253,10 +454,7 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Ready>(state)
assertIs<FirmwareUpdateMethod.Ble>(state.updateMethod)
// Mock extraction that returns null (no matching firmware found)
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns null
// Mock startUpdate — the firmwareUri should be the original URI since extraction returned null
// Mock startUpdate — the firmwareUri should be the original URI after filename validation.
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
.calls {
@Suppress("UNCHECKED_CAST")
@@ -265,8 +463,13 @@ class FirmwareUpdateViewModelFileTest {
null
}
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
val selectedUri = firmwareUri("firmware-tbeam-2.8.0.bin")
viewModel.prepareLocalFirmwareFile(selectedUri)
advanceUntilIdle()
viewModel.confirmLocalFirmwareFile()
advanceUntilIdle()
verifySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), firmwareUri = selectedUri) }
val finalState = viewModel.state.value
assertTrue(
@@ -278,7 +481,7 @@ class FirmwareUpdateViewModelFileTest {
}
@Test
fun `startUpdateFromFile is no-op when state is not Ready`() = runTest {
fun `prepareLocalFirmwareFile is no-op when state is not Ready`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
@@ -289,10 +492,10 @@ class FirmwareUpdateViewModelFileTest {
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip"))
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
advanceUntilIdle()
// Should still be Error — startUpdateFromFile returned early
// Should still be Error — prepareLocalFirmwareFile returned early
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
}
@@ -330,7 +533,7 @@ class FirmwareUpdateViewModelFileTest {
}
@Test
fun `startUpdateFromFile keeps AwaitingFileSave state for USB path`() = runTest {
fun `confirmLocalFirmwareFile keeps AwaitingFileSave state for USB path`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
viewModel = createViewModel()
@@ -343,7 +546,6 @@ class FirmwareUpdateViewModelFileTest {
fileName = "extracted.uf2",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns artifact
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
.calls {
@Suppress("UNCHECKED_CAST")
@@ -352,22 +554,21 @@ class FirmwareUpdateViewModelFileTest {
artifact
}
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
advanceUntilIdle()
viewModel.confirmLocalFirmwareFile()
advanceUntilIdle()
assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
}
@Test
fun `startUpdateFromFile cleans up on manager error state`() = runTest {
fun `confirmLocalFirmwareFile cleans up on manager error state`() = runTest {
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
viewModel = createViewModel()
advanceUntilIdle()
val extractedArtifact = FirmwareArtifact(uri = CommonUri.parse("file:///tmp/extracted.uf2"), isTemporary = true)
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns extractedArtifact
// Mock startUpdate to transition to Error
val errorText = UiText.DynamicString("Flash failed")
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
@@ -375,13 +576,139 @@ class FirmwareUpdateViewModelFileTest {
@Suppress("UNCHECKED_CAST")
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
updateState(FirmwareUpdateState.Error(errorText))
extractedArtifact
FirmwareArtifact(uri = CommonUri.parse("file:///tmp/extracted.uf2"), isTemporary = true)
}
viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip"))
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
advanceUntilIdle()
viewModel.confirmLocalFirmwareFile()
advanceUntilIdle()
val state = viewModel.state.value
assertIs<FirmwareUpdateState.Error>(state)
assertNull(viewModel.pendingLocalFirmwareFile.value)
verifySuspend { fileHandler.deleteFile(any()) }
}
@Test
fun `prepareLocalFirmwareFile is cancelled when state changes before resolution starts`() = runTest {
// This test verifies that clearPendingLocalFirmwareFile (called by cancelUpdate)
// cancels the in-flight prepareJob before its body executes. The _state.value !=
// currentState stale-state guard inside the coroutine is defense-in-depth and is
// not exercised by this test because StandardTestDispatcher never runs the body.
viewModel = createViewModel()
advanceUntilIdle()
val readyState = viewModel.state.value
assertIs<FirmwareUpdateState.Ready>(readyState)
// Start prepare — launches a coroutine that suspends on getDisplayName.
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
// Change state during the suspend — cancelUpdate cancels the prepare job and resets state.
viewModel.cancelUpdate()
advanceUntilIdle()
// Pending selection should NOT be set.
assertNull(viewModel.pendingLocalFirmwareFile.value)
// State should NOT be Error.
assertFalse(viewModel.state.value is FirmwareUpdateState.Error)
}
@Test
fun `prepareLocalFirmwareFile shows error when filename is unavailable`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
// Override the @BeforeTest stub: getDisplayName returns null → resolve cannot continue.
everySuspend { fileHandler.getDisplayName(any()) } returns null
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
advanceUntilIdle()
val state = viewModel.state.value
assertIs<FirmwareUpdateState.Error>(state)
}
@Test
fun `onCleared cleans up pending local firmware artifact`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
// Prepare a bundle file so pendingLocalFirmwareArtifact is set. nrf52 + BLE expects an
// .ota.zip payload, so the extracted artifact must match that extension or
// validateExtractedLocalFirmware rejects it and pending is never populated.
val extractedArtifact =
FirmwareArtifact(
uri = CommonUri.parse("file:///tmp/extracted-firmware-ota.zip"),
fileName = "firmware-tbeam-2.8.0-ota.zip",
isTemporary = true,
)
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-nrf52840-2.8.0.zip"))
advanceUntilIdle()
// Verify pending was set (artifact extracted from bundle).
assertNotNull(viewModel.pendingLocalFirmwareFile.value)
// Trigger onCleared via the idiomatic AndroidX ViewModelStore pattern.
val store = ViewModelStore()
store.put("firmwareUpdateViewModel", viewModel)
store.clear()
// applicationScope uses the same testDispatcher, so advanceUntilIdle flushes the
// ATOMIC + NonCancellable cleanup coroutine.
advanceUntilIdle()
// Verify the extracted artifact was cleaned up.
verifySuspend(atLeast(1)) { fileHandler.deleteFile(extractedArtifact) }
}
@Test
fun `confirmLocalFirmwareFile is no-op when pending selection was cancelled`() = runTest {
// After cancelUpdate clears the pending selection, confirmLocalFirmwareFile should
// read null and return immediately — no error surfaced, no update started.
viewModel = createViewModel()
advanceUntilIdle()
// Prepare a valid file so pending is set (nrf52 + BLE expects .ota.zip).
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
advanceUntilIdle()
assertNotNull(viewModel.pendingLocalFirmwareFile.value)
// State changes away from Ready before user confirms (cancelUpdate → Idle).
viewModel.cancelUpdate()
advanceUntilIdle()
assertNull(viewModel.pendingLocalFirmwareFile.value)
// Confirm should be a no-op: pending was cleared by cancelUpdate.
viewModel.confirmLocalFirmwareFile()
advanceUntilIdle()
assertNull(viewModel.pendingLocalFirmwareFile.value)
assertFalse(viewModel.state.value is FirmwareUpdateState.Error)
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
}
@Test
fun `prepareLocalFirmwareFile shows error for Unknown update method`() = runTest {
// TCP + nRF52 (non-ESP32) resolves to FirmwareUpdateMethod.Unknown — local files are
// not supported there; validateLocalFirmwareFileName returns UnsupportedUpdateMethod
// and localFirmwarePayloadExtension returns null (no bundle extraction attempted).
every { radioPrefs.devAddr } returns MutableStateFlow("t192.168.1.100")
viewModel = createViewModel()
advanceUntilIdle()
val state = viewModel.state.value
assertIs<FirmwareUpdateState.Ready>(state)
assertIs<FirmwareUpdateMethod.Unknown>(state.updateMethod)
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
advanceUntilIdle()
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
assertNull(viewModel.pendingLocalFirmwareFile.value)
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
}
}