From 8bfee2a59e57e56959f2dbb957e9abbe259596d6 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:36:14 -0500 Subject: [PATCH] feat(firmware): Confirm local firmware files before flashing (#6083) --- .skills/compose-ui/strings-index.txt | 12 + .../composeResources/values/strings.xml | 12 + .../firmware/AndroidFirmwareFileHandler.kt | 38 +- .../feature/firmware/FirmwareFileHandler.kt | 189 +++++++- .../feature/firmware/FirmwareUpdateActions.kt | 2 + .../feature/firmware/FirmwareUpdateScreen.kt | 61 ++- .../firmware/FirmwareUpdateViewModel.kt | 339 ++++++++++++++- .../firmware/CommonFirmwareRetrieverTest.kt | 2 + .../firmware/IsValidFirmwareFileTest.kt | 326 +++++++++++++- .../firmware/JvmFirmwareFileHandler.kt | 15 +- .../FirmwareUpdateViewModelFileTest.kt | 407 ++++++++++++++++-- 11 files changed, 1330 insertions(+), 73 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 58df5e50d..163409208 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 11db13fbc..9c736c4bd 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -615,9 +615,14 @@ Firmware update required. Almost there... Alpha + No firmware for %2$s (%3$s) was found in \"%1$s\". Battery too low (%1$d%). Please charge your device before updating. Checking for updates... + Start Flashing + Review the full selected filename before flashing. Flashing starts only after you confirm. + Confirm Firmware File Connecting to device (attempt %1$d/%2$d)... + Device connection changed while confirming. Please reselect the firmware file. High-five! Wait, copying firmware... Currently Installed: %1$s Device: %1$s @@ -633,9 +638,11 @@ Error: %1$s Extracting firmware... Update failed + Could not read the selected file name. Choose a local firmware file with a visible filename. Flashing device, please wait... Hang tight, we are working on it... Firmware hash rejected. Device may require hash provisioning or bootloader update. + The selected file \"%1$s\" does not match %2$s (%3$s). Choose the firmware file for this target. Keep your device close to your phone. Update To: %1$s Local File @@ -643,6 +650,7 @@ Update via %1$s USB File Transfer WiFi OTA + Firmware target information is unavailable for this device. No device connected Node user information is missing. Could not find firmware for %1$s in release. @@ -650,6 +658,9 @@ 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. Rebooting to DFU... Release Notes + %1$s requires a target-matching .bin file. + %1$s requires a target-matching OTA .zip file. + %1$s requires a target-matching .uf2 file. Could not retrieve firmware file. Retry Please save the .uf2 file to your device's DFU drive. @@ -667,6 +678,7 @@ Unknown hardware model: %1$d Unknown remote release Firmware updates are not supported over this connection for this device. Connect a different way (e.g. Bluetooth) to update. + Local firmware files are not supported for this update method. Uploading firmware... %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. USB Update failed diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 091dc424b..186bb532f 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -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 = withContext(ioDispatcher) { val entries = mutableMapOf() diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index 158b268f0..9e3dde71e 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -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 { + 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) + } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt index a54a1328a..aa65f43a7 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt @@ -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, diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 4ff36840d..64f963cdb 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -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( diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index b71eb3d81..3f46c06d3 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -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(null) val currentFirmwareVersion = _currentFirmwareVersion.asStateFlow() + private val _pendingLocalFirmwareFile = MutableStateFlow(null) + val pendingLocalFirmwareFile: StateFlow = _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 } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt index 19407d1a5..67c46f83c 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt @@ -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 = emptyMap() override suspend fun deleteFile(file: FirmwareArtifact) {} diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt index ea620d57a..3b76a7872 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt @@ -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) + } } diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt index 7f945b747..82bbfb542 100644 --- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt @@ -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 = withContext(ioDispatcher) { val entries = mutableMapOf() @@ -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() diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index 11661e2e8..f053915f0 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -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(state) assertIs(state.updateMethod) - viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip")) + viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip")) + advanceUntilIdle() + viewModel.confirmLocalFirmwareFile() advanceUntilIdle() assertIs(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(state) assertIs(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(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() + val allowExtraction = CompletableDeferred() + 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(viewModel.state.value) + val message = assertIs(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() + 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("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(state) assertIs(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(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(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(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(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(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(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(state) + assertIs(state.updateMethod) + + viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip")) + advanceUntilIdle() + + assertIs(viewModel.state.value) + assertNull(viewModel.pendingLocalFirmwareFile.value) + verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) } + } }