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()) }
+ }
}