mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-09 12:55:45 -04:00
feat(firmware): Confirm local firmware files before flashing (#6083)
This commit is contained in:
12
.skills/compose-ui/strings-index.txt
generated
12
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -615,9 +615,14 @@
|
||||
<string name="firmware_too_old">Firmware update required.</string>
|
||||
<string name="firmware_update_almost_there">Almost there...</string>
|
||||
<string name="firmware_update_alpha">Alpha</string>
|
||||
<string name="firmware_update_archive_missing_target">No firmware for %2$s (%3$s) was found in \"%1$s\".</string>
|
||||
<string name="firmware_update_battery_low">Battery too low (%1$d%). Please charge your device before updating.</string>
|
||||
<string name="firmware_update_checking">Checking for updates...</string>
|
||||
<string name="firmware_update_confirm_file_button">Start Flashing</string>
|
||||
<string name="firmware_update_confirm_file_message">Review the full selected filename before flashing. Flashing starts only after you confirm.</string>
|
||||
<string name="firmware_update_confirm_file_title">Confirm Firmware File</string>
|
||||
<string name="firmware_update_connecting_attempt">Connecting to device (attempt %1$d/%2$d)...</string>
|
||||
<string name="firmware_update_context_changed">Device connection changed while confirming. Please reselect the firmware file.</string>
|
||||
<string name="firmware_update_copying">High-five! Wait, copying firmware...</string>
|
||||
<string name="firmware_update_currently_installed">Currently Installed: %1$s</string>
|
||||
<string name="firmware_update_device">Device: %1$s</string>
|
||||
@@ -633,9 +638,11 @@
|
||||
<string name="firmware_update_error">Error: %1$s</string>
|
||||
<string name="firmware_update_extracting">Extracting firmware...</string>
|
||||
<string name="firmware_update_failed">Update failed</string>
|
||||
<string name="firmware_update_filename_unavailable">Could not read the selected file name. Choose a local firmware file with a visible filename.</string>
|
||||
<string name="firmware_update_flashing">Flashing device, please wait...</string>
|
||||
<string name="firmware_update_hang_tight">Hang tight, we are working on it...</string>
|
||||
<string name="firmware_update_hash_rejected">Firmware hash rejected. Device may require hash provisioning or bootloader update.</string>
|
||||
<string name="firmware_update_invalid_local_file_detail">The selected file \"%1$s\" does not match %2$s (%3$s). Choose the firmware file for this target.</string>
|
||||
<string name="firmware_update_keep_device_close">Keep your device close to your phone.</string>
|
||||
<string name="firmware_update_latest">Update To: %1$s</string>
|
||||
<string name="firmware_update_local_file">Local File</string>
|
||||
@@ -643,6 +650,7 @@
|
||||
<string name="firmware_update_method_detail">Update via %1$s</string>
|
||||
<string name="firmware_update_method_usb">USB File Transfer</string>
|
||||
<string name="firmware_update_method_wifi">WiFi OTA</string>
|
||||
<string name="firmware_update_missing_target">Firmware target information is unavailable for this device.</string>
|
||||
<string name="firmware_update_no_device">No device connected</string>
|
||||
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
|
||||
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
|
||||
@@ -650,6 +658,9 @@
|
||||
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
|
||||
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
|
||||
<string name="firmware_update_release_notes">Release Notes</string>
|
||||
<string name="firmware_update_requires_bin">%1$s requires a target-matching .bin file.</string>
|
||||
<string name="firmware_update_requires_ota_zip">%1$s requires a target-matching OTA .zip file.</string>
|
||||
<string name="firmware_update_requires_uf2">%1$s requires a target-matching .uf2 file.</string>
|
||||
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
|
||||
<string name="firmware_update_retry">Retry</string>
|
||||
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device's DFU drive.</string>
|
||||
@@ -667,6 +678,7 @@
|
||||
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
|
||||
<string name="firmware_update_unknown_release">Unknown remote release</string>
|
||||
<string name="firmware_update_unsupported_transport">Firmware updates are not supported over this connection for this device. Connect a different way (e.g. Bluetooth) to update.</string>
|
||||
<string name="firmware_update_unsupported_update_method">Local firmware files are not supported for this update method.</string>
|
||||
<string name="firmware_update_uploading">Uploading firmware...</string>
|
||||
<string name="firmware_update_usb_bootloader_warning">%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.</string>
|
||||
<string name="firmware_update_usb_failed">USB Update failed</string>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.meshtastic.feature.firmware
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.OpenableColumns
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.eygraber.uri.toAndroidUri
|
||||
import io.ktor.client.HttpClient
|
||||
@@ -134,7 +135,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
preferredFilename: String?,
|
||||
): FirmwareArtifact? = withContext(ioDispatcher) {
|
||||
val localZipFile = zipFile.toLocalFileOrNull() ?: return@withContext null
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
val target = hardware.effectiveTarget
|
||||
if (target.isEmpty() && preferredFilename == null) return@withContext null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
@@ -169,6 +170,8 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
entry = zipInput.nextEntry
|
||||
}
|
||||
}
|
||||
// Prefer the shortest matching entry name — official release bundles contain one
|
||||
// matching firmware per target; the heuristic picks the canonical name if multiple match.
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second?.toFirmwareArtifact()
|
||||
}
|
||||
|
||||
@@ -178,7 +181,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
fileExtension: String,
|
||||
preferredFilename: String?,
|
||||
): FirmwareArtifact? = withContext(ioDispatcher) {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
val target = hardware.effectiveTarget
|
||||
if (target.isEmpty() && preferredFilename == null) return@withContext null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
@@ -255,6 +258,37 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
|
||||
tempFile.toFirmwareArtifact()
|
||||
}
|
||||
|
||||
override suspend fun getDisplayName(uri: CommonUri): String? = withContext(ioDispatcher) {
|
||||
val platformUri = uri.toAndroidUri()
|
||||
if (platformUri.scheme == "content") {
|
||||
// query() can throw SecurityException (revoked permission) or IllegalArgumentException
|
||||
// (malformed URI) even after the scheme guard; fall through to the file-scheme branch on failure.
|
||||
runCatching {
|
||||
context.contentResolver.query(
|
||||
platformUri,
|
||||
arrayOf(OpenableColumns.DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "Failed to query display name from content provider" } }
|
||||
.getOrNull()
|
||||
?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||
cursor
|
||||
.getString(nameIndex)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
return@withContext it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (platformUri.scheme == "file") uri.pathSegments.lastOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
|
||||
withContext(ioDispatcher) {
|
||||
val entries = mutableMapOf<String, ByteArray>()
|
||||
|
||||
@@ -64,6 +64,9 @@ interface FirmwareFileHandler {
|
||||
*/
|
||||
suspend fun importFromUri(uri: CommonUri): FirmwareArtifact?
|
||||
|
||||
/** Resolve a user-visible display filename for [uri], or `null` when the provider does not expose one. */
|
||||
suspend fun getDisplayName(uri: CommonUri): String?
|
||||
|
||||
/** Copy [source] to the platform URI [destinationUri], returning the number of bytes written. */
|
||||
suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long
|
||||
|
||||
@@ -111,18 +114,192 @@ interface FirmwareFileHandler {
|
||||
* Check whether [filename] is a valid firmware binary for [target] with the expected [fileExtension]. Excludes
|
||||
* non-firmware binaries that share the same extension (e.g. `littlefs-*`, `bleota*`).
|
||||
*/
|
||||
@Suppress("ComplexCondition")
|
||||
@Suppress("ComplexCondition") // excluded-binary + target/extension guards collapsed to one early-out
|
||||
internal fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
|
||||
if (
|
||||
filename.startsWith("littlefs-") ||
|
||||
filename.startsWith("bleota") ||
|
||||
filename.startsWith("mt-") ||
|
||||
filename.contains(".factory.")
|
||||
filename.contains(".factory.") ||
|
||||
target.isBlank() ||
|
||||
!filename.endsWith(fileExtension)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_.].*")
|
||||
return filename.endsWith(fileExtension) &&
|
||||
filename.contains(target) &&
|
||||
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
|
||||
|
||||
val targetToken = Regex.escape(target)
|
||||
val extensionToken = Regex.escape(fileExtension)
|
||||
val targetPattern = Regex("(^|.*[\\-_])$targetToken(([\\-_.](v?\\d|firmware)).*$extensionToken$|$extensionToken$)")
|
||||
return targetPattern.matches(filename)
|
||||
}
|
||||
|
||||
data class PendingLocalFirmwareFile(
|
||||
val uri: CommonUri,
|
||||
val fileName: String,
|
||||
val deviceName: String,
|
||||
val platformioTarget: String,
|
||||
val updateMethod: FirmwareUpdateMethod,
|
||||
val address: String,
|
||||
)
|
||||
|
||||
/** pioEnv-aware target: falls back to [DeviceHardware.hwModelSlug] when [DeviceHardware.platformioTarget] is unset. */
|
||||
internal val DeviceHardware.effectiveTarget: String
|
||||
get() = platformioTarget.ifEmpty { hwModelSlug }
|
||||
|
||||
internal fun validatePendingLocalFirmwareFile(
|
||||
pendingFile: PendingLocalFirmwareFile,
|
||||
currentState: FirmwareUpdateState.Ready,
|
||||
): LocalFirmwareFileValidation {
|
||||
val currentTarget = currentState.deviceHardware.effectiveTarget
|
||||
return when {
|
||||
// The user selected the file under a different connection (method or address) than the
|
||||
// one active now — the file may still match the target, but the confirmation context is stale.
|
||||
currentState.updateMethod != pendingFile.updateMethod || currentState.address != pendingFile.address ->
|
||||
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.ConfirmationContextChanged)
|
||||
|
||||
currentTarget != pendingFile.platformioTarget ->
|
||||
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.TargetMismatch)
|
||||
|
||||
// Context matches — revalidate the filename against the current method before flashing.
|
||||
else ->
|
||||
validateLocalFirmwareFileName(pendingFile.fileName, currentState.deviceHardware, currentState.updateMethod)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount") // two guard-clause early returns + trailing when; refactor would harm clarity
|
||||
internal fun preferredLocalFirmwareArchiveFilenames(
|
||||
archiveFileName: String,
|
||||
hardware: DeviceHardware,
|
||||
updateMethod: FirmwareUpdateMethod,
|
||||
): List<String> {
|
||||
val target = hardware.effectiveTarget.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||
val version = extractFirmwareVersion(archiveFileName) ?: return emptyList()
|
||||
return when (updateMethod) {
|
||||
FirmwareUpdateMethod.Ble ->
|
||||
if (hardware.isEsp32Arc) {
|
||||
listOf("firmware-$target-$version-update.bin", "firmware-$target-$version.bin")
|
||||
} else {
|
||||
listOf("firmware-$target-$version-ota.zip")
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.Wifi -> listOf("firmware-$target-$version-update.bin", "firmware-$target-$version.bin")
|
||||
|
||||
FirmwareUpdateMethod.Usb -> listOf("firmware-$target-$version.uf2")
|
||||
|
||||
FirmwareUpdateMethod.Unknown -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractFirmwareVersion(fileName: String): String? {
|
||||
val baseName = fileName.substringAfterLast('/').substringAfterLast('\\')
|
||||
return FIRMWARE_VERSION_PATTERN.find(baseName)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
||||
private val FIRMWARE_VERSION_PATTERN = Regex("""(?:^|[-_])v?(\d+(?:\.[0-9A-Za-z]+)+)(?=(?:[-_][A-Za-z]+)?\.[^.]+$)""")
|
||||
|
||||
internal sealed interface LocalFirmwareFileValidation {
|
||||
data object Valid : LocalFirmwareFileValidation
|
||||
|
||||
data class Invalid(val reason: LocalFirmwareFileValidationReason) : LocalFirmwareFileValidation
|
||||
}
|
||||
|
||||
internal enum class LocalFirmwareFileValidationReason {
|
||||
MissingTarget,
|
||||
MissingArchiveFirmware,
|
||||
RequiresOtaZip,
|
||||
RequiresBin,
|
||||
RequiresUf2,
|
||||
TargetMismatch,
|
||||
ConfirmationContextChanged,
|
||||
UnsupportedUpdateMethod,
|
||||
}
|
||||
|
||||
internal fun validateLocalFirmwareFileName(
|
||||
fileName: String,
|
||||
hardware: DeviceHardware,
|
||||
updateMethod: FirmwareUpdateMethod,
|
||||
): LocalFirmwareFileValidation {
|
||||
val normalizedFileName = fileName.substringAfterLast('/').substringAfterLast('\\').lowercase()
|
||||
val target = hardware.effectiveTarget.lowercase()
|
||||
if (target.isBlank()) {
|
||||
return LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.MissingTarget)
|
||||
}
|
||||
|
||||
return when (updateMethod) {
|
||||
FirmwareUpdateMethod.Ble ->
|
||||
if (hardware.isEsp32Arc) {
|
||||
validateEsp32LocalFirmware(normalizedFileName, target)
|
||||
} else {
|
||||
validateNrf52LocalFirmware(normalizedFileName, target)
|
||||
}
|
||||
|
||||
FirmwareUpdateMethod.Wifi -> validateEsp32LocalFirmware(normalizedFileName, target)
|
||||
|
||||
FirmwareUpdateMethod.Usb -> validateUsbLocalFirmware(normalizedFileName, target)
|
||||
|
||||
FirmwareUpdateMethod.Unknown ->
|
||||
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.UnsupportedUpdateMethod)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the firmware payload file extension this platform's update handler expects to receive.
|
||||
*
|
||||
* nRF52 BLE consumes the Nordic DFU `.zip` package directly via [SecureDfuHandler]; ESP32 (BLE or WiFi) streams a raw
|
||||
* `.bin`; USB copies a `.uf2` to the mass-storage drive. Returns `null` for [FirmwareUpdateMethod.Unknown], which does
|
||||
* not support local files.
|
||||
*/
|
||||
internal fun localFirmwarePayloadExtension(hardware: DeviceHardware, updateMethod: FirmwareUpdateMethod): String? =
|
||||
when (updateMethod) {
|
||||
FirmwareUpdateMethod.Ble -> if (hardware.isEsp32Arc) ".bin" else ".zip"
|
||||
FirmwareUpdateMethod.Wifi -> ".bin"
|
||||
FirmwareUpdateMethod.Usb -> ".uf2"
|
||||
FirmwareUpdateMethod.Unknown -> null
|
||||
}
|
||||
|
||||
private fun validateNrf52LocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
|
||||
validateSuffixedLocalFirmware(
|
||||
fileName = fileName,
|
||||
target = target,
|
||||
requiredSuffix = "-ota.zip",
|
||||
fileExtension = ".zip",
|
||||
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresOtaZip,
|
||||
)
|
||||
|
||||
private fun validateEsp32LocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
|
||||
validateSuffixedLocalFirmware(
|
||||
fileName = fileName,
|
||||
target = target,
|
||||
requiredSuffix = ".bin",
|
||||
fileExtension = ".bin",
|
||||
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresBin,
|
||||
)
|
||||
|
||||
private fun validateUsbLocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
|
||||
validateSuffixedLocalFirmware(
|
||||
fileName = fileName,
|
||||
target = target,
|
||||
requiredSuffix = ".uf2",
|
||||
fileExtension = ".uf2",
|
||||
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresUf2,
|
||||
)
|
||||
|
||||
private fun validateSuffixedLocalFirmware(
|
||||
fileName: String,
|
||||
target: String,
|
||||
requiredSuffix: String,
|
||||
fileExtension: String,
|
||||
missingSuffixReason: LocalFirmwareFileValidationReason,
|
||||
): LocalFirmwareFileValidation {
|
||||
if (!fileName.endsWith(requiredSuffix)) {
|
||||
return LocalFirmwareFileValidation.Invalid(missingSuffixReason)
|
||||
}
|
||||
return validateTargetMatch(fileName, target, fileExtension)
|
||||
}
|
||||
|
||||
private fun validateTargetMatch(fileName: String, target: String, fileExtension: String): LocalFirmwareFileValidation =
|
||||
if (isValidFirmwareFile(fileName, target, fileExtension)) {
|
||||
LocalFirmwareFileValidation.Valid
|
||||
} else {
|
||||
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.TargetMismatch)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -61,18 +61,27 @@ import org.meshtastic.core.repository.isTcp
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.firmware_recovery_ble_failed
|
||||
import org.meshtastic.core.resources.firmware_update_archive_missing_target
|
||||
import org.meshtastic.core.resources.firmware_update_battery_low
|
||||
import org.meshtastic.core.resources.firmware_update_context_changed
|
||||
import org.meshtastic.core.resources.firmware_update_copying
|
||||
import org.meshtastic.core.resources.firmware_update_extracting
|
||||
import org.meshtastic.core.resources.firmware_update_failed
|
||||
import org.meshtastic.core.resources.firmware_update_filename_unavailable
|
||||
import org.meshtastic.core.resources.firmware_update_flashing
|
||||
import org.meshtastic.core.resources.firmware_update_invalid_local_file_detail
|
||||
import org.meshtastic.core.resources.firmware_update_method_ble
|
||||
import org.meshtastic.core.resources.firmware_update_method_usb
|
||||
import org.meshtastic.core.resources.firmware_update_method_wifi
|
||||
import org.meshtastic.core.resources.firmware_update_missing_target
|
||||
import org.meshtastic.core.resources.firmware_update_no_device
|
||||
import org.meshtastic.core.resources.firmware_update_node_info_missing
|
||||
import org.meshtastic.core.resources.firmware_update_requires_bin
|
||||
import org.meshtastic.core.resources.firmware_update_requires_ota_zip
|
||||
import org.meshtastic.core.resources.firmware_update_requires_uf2
|
||||
import org.meshtastic.core.resources.firmware_update_unknown_error
|
||||
import org.meshtastic.core.resources.firmware_update_unknown_hardware
|
||||
import org.meshtastic.core.resources.firmware_update_unsupported_update_method
|
||||
import org.meshtastic.core.resources.unknown
|
||||
|
||||
private const val DEVICE_DETACH_TIMEOUT = 30_000L
|
||||
@@ -87,7 +96,7 @@ private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
|
||||
* ViewModel driving the firmware update screen. Coordinates release checking, file retrieval, transport-specific update
|
||||
* execution, and post-update device verification.
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass")
|
||||
@KoinViewModel
|
||||
class FirmwareUpdateViewModel(
|
||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
@@ -120,8 +129,13 @@ class FirmwareUpdateViewModel(
|
||||
private val _currentFirmwareVersion = MutableStateFlow<String?>(null)
|
||||
val currentFirmwareVersion = _currentFirmwareVersion.asStateFlow()
|
||||
|
||||
private val _pendingLocalFirmwareFile = MutableStateFlow<PendingLocalFirmwareFile?>(null)
|
||||
val pendingLocalFirmwareFile: StateFlow<PendingLocalFirmwareFile?> = _pendingLocalFirmwareFile.asStateFlow()
|
||||
|
||||
private var updateJob: Job? = null
|
||||
private var prepareJob: Job? = null
|
||||
private var tempFirmwareFile: FirmwareArtifact? = null
|
||||
private var pendingLocalFirmwareArtifact: FirmwareArtifact? = null
|
||||
private var originalDeviceAddress: String? = null
|
||||
|
||||
/** Set when [checkForUpdates] enters recovery mode (disconnected + a saved record); consumed by [startUpdate]. */
|
||||
@@ -138,11 +152,22 @@ class FirmwareUpdateViewModel(
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
prepareJob?.cancel()
|
||||
prepareJob = null
|
||||
// viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
|
||||
// application-wide scope (SupervisorJob + ioDispatcher). ATOMIC start + NonCancellable
|
||||
// context keeps cleanup running even if something tries to cancel it mid-flight.
|
||||
val pendingArtifact = pendingLocalFirmwareArtifact
|
||||
pendingLocalFirmwareArtifact = null
|
||||
applicationScope.launch(start = CoroutineStart.ATOMIC) {
|
||||
withContext(NonCancellable) { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) }
|
||||
withContext(NonCancellable) {
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
|
||||
if (pendingArtifact != null && pendingArtifact != tempFirmwareFile) {
|
||||
cleanupTemporaryFiles(fileHandler, pendingArtifact)
|
||||
}
|
||||
safeCatching { fileHandler.cleanupAllTemporaryFiles() }
|
||||
.onFailure { Logger.w(it) { "Failed to cleanup remaining temp files" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +178,7 @@ class FirmwareUpdateViewModel(
|
||||
|
||||
fun cancelUpdate() {
|
||||
updateJob?.cancel()
|
||||
clearPendingLocalFirmwareFile()
|
||||
_state.value = FirmwareUpdateState.Idle
|
||||
checkForUpdates()
|
||||
}
|
||||
@@ -160,6 +186,7 @@ class FirmwareUpdateViewModel(
|
||||
@Suppress("LongMethod")
|
||||
fun checkForUpdates() {
|
||||
updateJob?.cancel()
|
||||
clearPendingLocalFirmwareFile()
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
_state.value = FirmwareUpdateState.Checking
|
||||
@@ -447,10 +474,226 @@ class FirmwareUpdateViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun startUpdateFromFile(uri: CommonUri) {
|
||||
fun prepareLocalFirmwareFile(uri: CommonUri) {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
clearPendingLocalFirmwareFile()
|
||||
prepareJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val fileName =
|
||||
safeCatching { fileHandler.getDisplayName(uri)?.takeIf { it.isNotBlank() } }
|
||||
.getOrElse { e ->
|
||||
Logger.w(e) { "Failed to resolve local firmware filename" }
|
||||
null
|
||||
}
|
||||
|
||||
// State may have changed during the suspend call (e.g. cancelUpdate, checkForUpdates).
|
||||
// Do not write errors or reopen the confirmation dialog for a stale selection.
|
||||
when {
|
||||
_state.value != currentState -> Unit
|
||||
|
||||
fileName == null ->
|
||||
_state.value =
|
||||
FirmwareUpdateState.Error(
|
||||
UiText.Resource(Res.string.firmware_update_filename_unavailable),
|
||||
)
|
||||
|
||||
else -> {
|
||||
val resolution = resolveLocalFirmwareFile(uri, fileName, currentState)
|
||||
if (_state.value != currentState) {
|
||||
cleanupResolvedLocalFirmwareFile(resolution)
|
||||
} else {
|
||||
applyLocalFirmwareResolution(resolution, currentState)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Error preparing local firmware file" }
|
||||
if (_state.value == currentState) {
|
||||
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmLocalFirmwareFile() {
|
||||
val pendingSelection = _pendingLocalFirmwareFile.value ?: return
|
||||
val pendingArtifact = pendingLocalFirmwareArtifact
|
||||
_pendingLocalFirmwareFile.value = null
|
||||
pendingLocalFirmwareArtifact = null
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready
|
||||
if (currentState != null) {
|
||||
val validation = validatePendingLocalFirmwareFile(pendingSelection, currentState)
|
||||
if (validation is LocalFirmwareFileValidation.Invalid) {
|
||||
_state.value =
|
||||
FirmwareUpdateState.Error(
|
||||
localFirmwareValidationError(validation.reason, pendingSelection.fileName, currentState),
|
||||
)
|
||||
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
|
||||
} else {
|
||||
startUpdateFromFile(pendingSelection.uri, pendingArtifact)
|
||||
}
|
||||
} else {
|
||||
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissLocalFirmwareFile() {
|
||||
clearPendingLocalFirmwareFile()
|
||||
}
|
||||
|
||||
private suspend fun resolveLocalFirmwareFile(
|
||||
uri: CommonUri,
|
||||
fileName: String,
|
||||
state: FirmwareUpdateState.Ready,
|
||||
): LocalFirmwareResolution {
|
||||
val validation = validateLocalFirmwareFileName(fileName, state.deviceHardware, state.updateMethod)
|
||||
return when (validation) {
|
||||
LocalFirmwareFileValidation.Valid -> LocalFirmwareResolution.Resolved(uri = uri, fileName = fileName)
|
||||
|
||||
is LocalFirmwareFileValidation.Invalid ->
|
||||
if (shouldTryLocalFirmwareBundle(fileName, validation.reason)) {
|
||||
resolveLocalFirmwareBundle(uri, fileName, state, validation.reason)
|
||||
} else {
|
||||
LocalFirmwareResolution.Invalid(reason = validation.reason, fileName = fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveLocalFirmwareBundle(
|
||||
uri: CommonUri,
|
||||
fileName: String,
|
||||
state: FirmwareUpdateState.Ready,
|
||||
fallbackReason: LocalFirmwareFileValidationReason,
|
||||
): LocalFirmwareResolution {
|
||||
val payloadExtension = localFirmwarePayloadExtension(state.deviceHardware, state.updateMethod)
|
||||
return if (payloadExtension == null) {
|
||||
LocalFirmwareResolution.Invalid(reason = fallbackReason, fileName = fileName)
|
||||
} else {
|
||||
val extractingState =
|
||||
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_extracting)))
|
||||
_state.value = extractingState
|
||||
try {
|
||||
val extractedArtifact = extractLocalFirmwareArchive(uri, fileName, state, payloadExtension)
|
||||
if (extractedArtifact == null) {
|
||||
LocalFirmwareResolution.Invalid(
|
||||
reason = LocalFirmwareFileValidationReason.MissingArchiveFirmware,
|
||||
fileName = fileName,
|
||||
)
|
||||
} else {
|
||||
validateExtractedLocalFirmware(extractedArtifact, fileName, state)
|
||||
}
|
||||
} finally {
|
||||
if (_state.value == extractingState) {
|
||||
_state.value = state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun extractLocalFirmwareArchive(
|
||||
uri: CommonUri,
|
||||
fileName: String,
|
||||
state: FirmwareUpdateState.Ready,
|
||||
payloadExtension: String,
|
||||
): FirmwareArtifact? {
|
||||
val preferredFilenames =
|
||||
preferredLocalFirmwareArchiveFilenames(fileName, state.deviceHardware, state.updateMethod)
|
||||
for (preferredFilename in preferredFilenames) {
|
||||
safeCatching { fileHandler.extractFirmware(uri, state.deviceHardware, payloadExtension, preferredFilename) }
|
||||
.onFailure { e -> Logger.w(e) { "Failed to extract preferred local firmware $preferredFilename" } }
|
||||
.getOrNull()
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
return safeCatching { fileHandler.extractFirmware(uri, state.deviceHardware, payloadExtension) }
|
||||
.getOrElse { e ->
|
||||
Logger.w(e) { "Failed to extract local firmware archive" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateExtractedLocalFirmware(
|
||||
extractedArtifact: FirmwareArtifact,
|
||||
fallbackFileName: String,
|
||||
state: FirmwareUpdateState.Ready,
|
||||
): LocalFirmwareResolution {
|
||||
val extractedFileName = extractedArtifact.fileName
|
||||
val extractedValidation =
|
||||
extractedFileName?.let { validateLocalFirmwareFileName(it, state.deviceHardware, state.updateMethod) }
|
||||
?: LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.MissingArchiveFirmware)
|
||||
return if (extractedValidation == LocalFirmwareFileValidation.Valid && extractedFileName != null) {
|
||||
LocalFirmwareResolution.Resolved(
|
||||
uri = extractedArtifact.uri,
|
||||
fileName = extractedFileName,
|
||||
temporaryArtifact = extractedArtifact.takeIf { it.isTemporary },
|
||||
)
|
||||
} else {
|
||||
cleanupPendingLocalFirmwareArtifact(extractedArtifact)
|
||||
LocalFirmwareResolution.Invalid(
|
||||
reason = (extractedValidation as LocalFirmwareFileValidation.Invalid).reason,
|
||||
fileName = extractedFileName ?: fallbackFileName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyLocalFirmwareResolution(resolution: LocalFirmwareResolution, state: FirmwareUpdateState.Ready) {
|
||||
when (resolution) {
|
||||
is LocalFirmwareResolution.Invalid ->
|
||||
_state.value =
|
||||
FirmwareUpdateState.Error(
|
||||
localFirmwareValidationError(resolution.reason, resolution.fileName, state),
|
||||
)
|
||||
|
||||
is LocalFirmwareResolution.Resolved -> {
|
||||
pendingLocalFirmwareArtifact = resolution.temporaryArtifact
|
||||
_pendingLocalFirmwareFile.value =
|
||||
PendingLocalFirmwareFile(
|
||||
uri = resolution.uri,
|
||||
fileName = resolution.fileName,
|
||||
deviceName = state.deviceHardware.displayName,
|
||||
platformioTarget = state.deviceHardware.effectiveTarget,
|
||||
updateMethod = state.updateMethod,
|
||||
address = state.address,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupResolvedLocalFirmwareFile(resolution: LocalFirmwareResolution) {
|
||||
if (resolution is LocalFirmwareResolution.Resolved) {
|
||||
cleanupPendingLocalFirmwareArtifact(resolution.temporaryArtifact)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingLocalFirmwareFile() {
|
||||
prepareJob?.cancel()
|
||||
prepareJob = null
|
||||
val artifact = pendingLocalFirmwareArtifact
|
||||
_pendingLocalFirmwareFile.value = null
|
||||
pendingLocalFirmwareArtifact = null
|
||||
cleanupPendingLocalFirmwareArtifact(artifact)
|
||||
}
|
||||
|
||||
private fun cleanupPendingLocalFirmwareArtifact(artifact: FirmwareArtifact?) {
|
||||
artifact
|
||||
?.takeIf { it.isTemporary }
|
||||
?.let {
|
||||
viewModelScope.launch {
|
||||
safeCatching { fileHandler.deleteFile(it) }
|
||||
.onFailure { e -> Logger.w(e) { "Failed to cleanup pending local firmware file" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUpdateFromFile(uri: CommonUri, pendingArtifact: FirmwareArtifact? = null) {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) {
|
||||
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_no_device))
|
||||
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
|
||||
return
|
||||
}
|
||||
originalDeviceAddress = radioPrefs.devAddr.value
|
||||
@@ -459,25 +702,20 @@ class FirmwareUpdateViewModel(
|
||||
updateJob =
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_state.value =
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_extracting)),
|
||||
)
|
||||
val extension = if (currentState.updateMethod is FirmwareUpdateMethod.Ble) ".zip" else ".uf2"
|
||||
val extractedFile = fileHandler.extractFirmware(uri, currentState.deviceHardware, extension)
|
||||
|
||||
tempFirmwareFile = extractedFile
|
||||
val firmwareUri = extractedFile?.uri ?: uri
|
||||
|
||||
val updateArtifact =
|
||||
firmwareUpdateManager.startUpdate(
|
||||
release = FirmwareRelease(id = LOCAL_RELEASE_ID, zipUrl = "", releaseNotes = ""),
|
||||
hardware = currentState.deviceHardware,
|
||||
address = currentState.address,
|
||||
updateState = { _state.value = it },
|
||||
firmwareUri = firmwareUri,
|
||||
firmwareUri = uri,
|
||||
)
|
||||
tempFirmwareFile = updateArtifact ?: extractedFile
|
||||
tempFirmwareFile = updateArtifact?.takeIf { it.isTemporary } ?: pendingArtifact
|
||||
// If the handler created its own temp copy (e.g. ESP32 importFromUri),
|
||||
// clean up the extracted bundle artifact to prevent a leak.
|
||||
if (pendingArtifact != null && pendingArtifact != tempFirmwareFile) {
|
||||
cleanupTemporaryFiles(fileHandler, pendingArtifact)
|
||||
}
|
||||
|
||||
when (val finalState = _state.value) {
|
||||
is FirmwareUpdateState.Success ->
|
||||
@@ -501,10 +739,51 @@ class FirmwareUpdateViewModel(
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Error starting update from file" }
|
||||
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
|
||||
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile ?: pendingArtifact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun localFirmwareValidationError(
|
||||
reason: LocalFirmwareFileValidationReason,
|
||||
fileName: String,
|
||||
state: FirmwareUpdateState.Ready,
|
||||
): UiText = when (reason) {
|
||||
LocalFirmwareFileValidationReason.MissingArchiveFirmware ->
|
||||
UiText.Resource(
|
||||
Res.string.firmware_update_archive_missing_target,
|
||||
fileName,
|
||||
state.deviceHardware.displayName,
|
||||
state.deviceHardware.effectiveTarget,
|
||||
)
|
||||
|
||||
LocalFirmwareFileValidationReason.MissingTarget ->
|
||||
UiText.Resource(Res.string.firmware_update_missing_target)
|
||||
|
||||
LocalFirmwareFileValidationReason.UnsupportedUpdateMethod ->
|
||||
UiText.Resource(Res.string.firmware_update_unsupported_update_method)
|
||||
|
||||
LocalFirmwareFileValidationReason.RequiresOtaZip ->
|
||||
UiText.Resource(Res.string.firmware_update_requires_ota_zip, state.deviceHardware.displayName)
|
||||
|
||||
LocalFirmwareFileValidationReason.RequiresBin ->
|
||||
UiText.Resource(Res.string.firmware_update_requires_bin, state.deviceHardware.displayName)
|
||||
|
||||
LocalFirmwareFileValidationReason.RequiresUf2 ->
|
||||
UiText.Resource(Res.string.firmware_update_requires_uf2, state.deviceHardware.displayName)
|
||||
|
||||
LocalFirmwareFileValidationReason.ConfirmationContextChanged ->
|
||||
UiText.Resource(Res.string.firmware_update_context_changed)
|
||||
|
||||
LocalFirmwareFileValidationReason.TargetMismatch ->
|
||||
UiText.Resource(
|
||||
Res.string.firmware_update_invalid_local_file_detail,
|
||||
fileName,
|
||||
state.deviceHardware.displayName,
|
||||
state.deviceHardware.effectiveTarget,
|
||||
)
|
||||
}
|
||||
|
||||
fun dismissBootloaderWarningForCurrentDevice() {
|
||||
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -591,14 +870,36 @@ class FirmwareUpdateViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface LocalFirmwareResolution {
|
||||
data class Resolved(val uri: CommonUri, val fileName: String, val temporaryArtifact: FirmwareArtifact? = null) :
|
||||
LocalFirmwareResolution
|
||||
|
||||
data class Invalid(val reason: LocalFirmwareFileValidationReason, val fileName: String) : LocalFirmwareResolution
|
||||
}
|
||||
|
||||
private fun shouldTryLocalFirmwareBundle(fileName: String, reason: LocalFirmwareFileValidationReason): Boolean {
|
||||
val normalizedFileName = fileName.substringAfterLast('/').substringAfterLast('\\').lowercase()
|
||||
return normalizedFileName.endsWith(".zip") &&
|
||||
when (reason) {
|
||||
LocalFirmwareFileValidationReason.RequiresOtaZip,
|
||||
LocalFirmwareFileValidationReason.RequiresBin,
|
||||
LocalFirmwareFileValidationReason.RequiresUf2,
|
||||
-> true
|
||||
|
||||
LocalFirmwareFileValidationReason.MissingArchiveFirmware,
|
||||
LocalFirmwareFileValidationReason.MissingTarget,
|
||||
LocalFirmwareFileValidationReason.TargetMismatch,
|
||||
LocalFirmwareFileValidationReason.ConfirmationContextChanged,
|
||||
LocalFirmwareFileValidationReason.UnsupportedUpdateMethod,
|
||||
-> false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupTemporaryFiles(
|
||||
fileHandler: FirmwareFileHandler,
|
||||
tempFirmwareFile: FirmwareArtifact?,
|
||||
): FirmwareArtifact? {
|
||||
safeCatching {
|
||||
tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) }
|
||||
fileHandler.cleanupAllTemporaryFiles()
|
||||
}
|
||||
safeCatching { tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) } }
|
||||
.onFailure { e -> Logger.w(e) { "Failed to cleanup temp files" } }
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -472,6 +472,8 @@ abstract class CommonFirmwareRetrieverTest {
|
||||
|
||||
override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = null
|
||||
|
||||
override suspend fun getDisplayName(uri: CommonUri): String? = uri.pathSegments.lastOrNull()
|
||||
|
||||
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> = emptyMap()
|
||||
|
||||
override suspend fun deleteFile(file: FirmwareArtifact) {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,19 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl
|
||||
dest.toFirmwareArtifact()
|
||||
}
|
||||
|
||||
override suspend fun getDisplayName(uri: CommonUri): String? = withContext(ioDispatcher) {
|
||||
val localFile = uri.toLocalFileOrNull()
|
||||
localFile?.name?.takeIf { it.isNotBlank() }
|
||||
?: run {
|
||||
val scheme = runCatching { URI(uri.toString()).scheme }.getOrNull()
|
||||
if (scheme == "file") {
|
||||
uri.pathSegments.lastOrNull()?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
|
||||
withContext(ioDispatcher) {
|
||||
val entries = mutableMapOf<String, ByteArray>()
|
||||
@@ -201,7 +214,7 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl
|
||||
fileExtension: String,
|
||||
preferredFilename: String?,
|
||||
): FirmwareArtifact? {
|
||||
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
|
||||
val target = hardware.effectiveTarget
|
||||
if (target.isEmpty() && preferredFilename == null) return null
|
||||
|
||||
val targetLowerCase = target.lowercase()
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.meshtastic.feature.firmware
|
||||
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.calls
|
||||
import dev.mokkery.answering.returns
|
||||
@@ -23,6 +24,10 @@ import dev.mokkery.every
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify.VerifyMode.Companion.atLeast
|
||||
import dev.mokkery.verify.VerifyMode.Companion.exactly
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -30,6 +35,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
@@ -40,19 +46,25 @@ import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.firmware_update_extracting
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* JVM-only ViewModel tests for paths that require [CommonUri.parse] (which delegates to `java.net.URI` on JVM). Covers
|
||||
* [FirmwareUpdateViewModel.saveDfuFile] and [FirmwareUpdateViewModel.startUpdateFromFile].
|
||||
* [FirmwareUpdateViewModel.saveDfuFile] and [FirmwareUpdateViewModel.confirmLocalFirmwareFile].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class FirmwareUpdateViewModelFileTest {
|
||||
@@ -102,6 +114,8 @@ class FirmwareUpdateViewModelFileTest {
|
||||
|
||||
every { fileHandler.cleanupAllTemporaryFiles() } returns Unit
|
||||
everySuspend { fileHandler.deleteFile(any()) } returns Unit
|
||||
everySuspend { fileHandler.getDisplayName(any()) } calls { (it.args[0] as CommonUri).pathSegments.lastOrNull() }
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns null
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
@@ -123,6 +137,8 @@ class FirmwareUpdateViewModelFileTest {
|
||||
TestApplicationCoroutineScope(testDispatcher),
|
||||
)
|
||||
|
||||
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// saveDfuFile()
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -154,11 +170,11 @@ class FirmwareUpdateViewModelFileTest {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// startUpdateFromFile()
|
||||
// prepareLocalFirmwareFile() / confirmLocalFirmwareFile()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile with BLE and invalid address shows error`() = runTest {
|
||||
fun `confirmLocalFirmwareFile with BLE and invalid address shows error`() = runTest {
|
||||
// Use a BLE prefix but non-MAC address to trigger validation failure
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("xnot-a-mac-address")
|
||||
|
||||
@@ -169,14 +185,16 @@ class FirmwareUpdateViewModelFileTest {
|
||||
assertIs<FirmwareUpdateState.Ready>(state)
|
||||
assertIs<FirmwareUpdateMethod.Ble>(state.updateMethod)
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip"))
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
|
||||
advanceUntilIdle()
|
||||
viewModel.confirmLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile extracts and starts update`() = runTest {
|
||||
fun `confirmLocalFirmwareFile starts update after pending selection`() = runTest {
|
||||
// Serial nRF52 → USB method (no BLE address validation)
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
|
||||
|
||||
@@ -187,15 +205,6 @@ class FirmwareUpdateViewModelFileTest {
|
||||
assertIs<FirmwareUpdateState.Ready>(state)
|
||||
assertIs<FirmwareUpdateMethod.Usb>(state.updateMethod)
|
||||
|
||||
// Mock extraction
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = CommonUri.parse("file:///tmp/extracted-firmware.uf2"),
|
||||
fileName = "extracted-firmware.uf2",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
// Mock startUpdate to transition to Success
|
||||
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
.calls {
|
||||
@@ -205,7 +214,13 @@ class FirmwareUpdateViewModelFileTest {
|
||||
null
|
||||
}
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("firmware-tbeam-2.8.0.uf2", viewModel.pendingLocalFirmwareFile.value?.fileName)
|
||||
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
|
||||
viewModel.confirmLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should reach Success, Verifying, or VerificationFailed (verification timeout in test)
|
||||
@@ -219,27 +234,213 @@ class FirmwareUpdateViewModelFileTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile handles extraction failure`() = runTest {
|
||||
fun `dismissLocalFirmwareFile clears pending selection`() = runTest {
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Mock extraction to throw
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } calls
|
||||
{
|
||||
throw RuntimeException("Corrupt zip file")
|
||||
}
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/corrupt.zip"))
|
||||
assertEquals("firmware-tbeam-2.8.0.uf2", viewModel.pendingLocalFirmwareFile.value?.fileName)
|
||||
|
||||
viewModel.dismissLocalFirmwareFile()
|
||||
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile shows error when archive has no matching target`() = runTest {
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("corrupt.zip"))
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertIs<FirmwareUpdateState.Error>(state)
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile passes BLE extension for BLE method`() = runTest {
|
||||
fun `prepareLocalFirmwareFile extracts nrf firmware from release bundle`() = runTest {
|
||||
val bundleUri = firmwareUri("firmware-nrf52840-2.8.0.zip")
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = firmwareUri("firmware-tbeam-2.8.0-ota.zip"),
|
||||
fileName = "firmware-tbeam-2.8.0-ota.zip",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(bundleUri)
|
||||
advanceUntilIdle()
|
||||
|
||||
val pendingFile = viewModel.pendingLocalFirmwareFile.value
|
||||
assertEquals("firmware-tbeam-2.8.0-ota.zip", pendingFile?.fileName)
|
||||
assertEquals(extractedArtifact.uri, pendingFile?.uri)
|
||||
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile shows extracting progress while resolving bundle`() = runTest {
|
||||
val bundleUri = firmwareUri("firmware-nrf52840-2.8.0.zip")
|
||||
val extractionStarted = CompletableDeferred<Unit>()
|
||||
val allowExtraction = CompletableDeferred<Unit>()
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = firmwareUri("firmware-tbeam-2.8.0-ota.zip"),
|
||||
fileName = "firmware-tbeam-2.8.0-ota.zip",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) }
|
||||
.calls {
|
||||
extractionStarted.complete(Unit)
|
||||
allowExtraction.await()
|
||||
extractedArtifact
|
||||
}
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(bundleUri)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(extractionStarted.isCompleted)
|
||||
val processing = assertIs<FirmwareUpdateState.Processing>(viewModel.state.value)
|
||||
val message = assertIs<UiText.Resource>(processing.progressState.message)
|
||||
assertEquals(Res.string.firmware_update_extracting, message.res)
|
||||
|
||||
allowExtraction.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("firmware-tbeam-2.8.0-ota.zip", viewModel.pendingLocalFirmwareFile.value?.fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile extracts esp32 firmware from release bundle`() = runTest {
|
||||
val espHardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
|
||||
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
|
||||
Result.success(espHardware)
|
||||
val bundleUri = firmwareUri("firmware-esp32-2.8.0.zip")
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = firmwareUri("firmware-tbeam-2.8.0.bin"),
|
||||
fileName = "firmware-tbeam-2.8.0.bin",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(bundleUri)
|
||||
advanceUntilIdle()
|
||||
|
||||
val pendingFile = viewModel.pendingLocalFirmwareFile.value
|
||||
assertEquals("firmware-tbeam-2.8.0.bin", pendingFile?.fileName)
|
||||
assertEquals(extractedArtifact.uri, pendingFile?.uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile prefers esp32 update binary from release bundle`() = runTest {
|
||||
val espHardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
|
||||
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any()) } returns
|
||||
Result.success(espHardware)
|
||||
val requestedPreferredFilenames = mutableListOf<String?>()
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = firmwareUri("firmware-tbeam-2.7.15-update.bin"),
|
||||
fileName = "firmware-tbeam-2.7.15-update.bin",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) }
|
||||
.calls {
|
||||
val preferredFilename = it.args[3] as String?
|
||||
requestedPreferredFilenames += preferredFilename
|
||||
if (preferredFilename == "firmware-tbeam-2.7.15-update.bin") extractedArtifact else null
|
||||
}
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-esp32-2.7.15.zip"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf<String?>("firmware-tbeam-2.7.15-update.bin"), requestedPreferredFilenames)
|
||||
assertEquals("firmware-tbeam-2.7.15-update.bin", viewModel.pendingLocalFirmwareFile.value?.fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile extracts usb firmware from release bundle`() = runTest {
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
|
||||
val bundleUri = firmwareUri("firmware-nrf52840-2.8.0.zip")
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = firmwareUri("firmware-tbeam-2.8.0.uf2"),
|
||||
fileName = "firmware-tbeam-2.8.0.uf2",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(bundleUri)
|
||||
advanceUntilIdle()
|
||||
|
||||
val pendingFile = viewModel.pendingLocalFirmwareFile.value
|
||||
assertEquals("firmware-tbeam-2.8.0.uf2", pendingFile?.fileName)
|
||||
assertEquals(extractedArtifact.uri, pendingFile?.uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissLocalFirmwareFile deletes extracted bundle firmware`() = runTest {
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = firmwareUri("firmware-tbeam-2.8.0-ota.zip"),
|
||||
fileName = "firmware-tbeam-2.8.0-ota.zip",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-nrf52840-2.8.0.zip"))
|
||||
advanceUntilIdle()
|
||||
viewModel.dismissLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
verifySuspend { fileHandler.deleteFile(extractedArtifact) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkForUpdates clears pending local firmware selection`() = runTest {
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("firmware-tbeam-2.8.0-ota.zip", viewModel.pendingLocalFirmwareFile.value?.fileName)
|
||||
|
||||
viewModel.checkForUpdates()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmLocalFirmwareFile starts ESP32 BLE update without zip extraction`() = runTest {
|
||||
// BLE with valid MAC address
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("x11:22:33:44:55:66")
|
||||
val espHardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam")
|
||||
@@ -253,10 +454,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
assertIs<FirmwareUpdateState.Ready>(state)
|
||||
assertIs<FirmwareUpdateMethod.Ble>(state.updateMethod)
|
||||
|
||||
// Mock extraction that returns null (no matching firmware found)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns null
|
||||
|
||||
// Mock startUpdate — the firmwareUri should be the original URI since extraction returned null
|
||||
// Mock startUpdate — the firmwareUri should be the original URI after filename validation.
|
||||
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
.calls {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -265,8 +463,13 @@ class FirmwareUpdateViewModelFileTest {
|
||||
null
|
||||
}
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
|
||||
val selectedUri = firmwareUri("firmware-tbeam-2.8.0.bin")
|
||||
viewModel.prepareLocalFirmwareFile(selectedUri)
|
||||
advanceUntilIdle()
|
||||
viewModel.confirmLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), firmwareUri = selectedUri) }
|
||||
|
||||
val finalState = viewModel.state.value
|
||||
assertTrue(
|
||||
@@ -278,7 +481,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile is no-op when state is not Ready`() = runTest {
|
||||
fun `prepareLocalFirmwareFile is no-op when state is not Ready`() = runTest {
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -289,10 +492,10 @@ class FirmwareUpdateViewModelFileTest {
|
||||
|
||||
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip"))
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should still be Error — startUpdateFromFile returned early
|
||||
// Should still be Error — prepareLocalFirmwareFile returned early
|
||||
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
|
||||
}
|
||||
|
||||
@@ -330,7 +533,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile keeps AwaitingFileSave state for USB path`() = runTest {
|
||||
fun `confirmLocalFirmwareFile keeps AwaitingFileSave state for USB path`() = runTest {
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
|
||||
|
||||
viewModel = createViewModel()
|
||||
@@ -343,7 +546,6 @@ class FirmwareUpdateViewModelFileTest {
|
||||
fileName = "extracted.uf2",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns artifact
|
||||
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
.calls {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -352,22 +554,21 @@ class FirmwareUpdateViewModelFileTest {
|
||||
artifact
|
||||
}
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///downloads/firmware.zip"))
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
|
||||
advanceUntilIdle()
|
||||
viewModel.confirmLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startUpdateFromFile cleans up on manager error state`() = runTest {
|
||||
fun `confirmLocalFirmwareFile cleans up on manager error state`() = runTest {
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val extractedArtifact = FirmwareArtifact(uri = CommonUri.parse("file:///tmp/extracted.uf2"), isTemporary = true)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
// Mock startUpdate to transition to Error
|
||||
val errorText = UiText.DynamicString("Flash failed")
|
||||
everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
@@ -375,13 +576,139 @@ class FirmwareUpdateViewModelFileTest {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
|
||||
updateState(FirmwareUpdateState.Error(errorText))
|
||||
extractedArtifact
|
||||
FirmwareArtifact(uri = CommonUri.parse("file:///tmp/extracted.uf2"), isTemporary = true)
|
||||
}
|
||||
|
||||
viewModel.startUpdateFromFile(CommonUri.parse("file:///firmware.zip"))
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0.uf2"))
|
||||
advanceUntilIdle()
|
||||
viewModel.confirmLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertIs<FirmwareUpdateState.Error>(state)
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
verifySuspend { fileHandler.deleteFile(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile is cancelled when state changes before resolution starts`() = runTest {
|
||||
// This test verifies that clearPendingLocalFirmwareFile (called by cancelUpdate)
|
||||
// cancels the in-flight prepareJob before its body executes. The _state.value !=
|
||||
// currentState stale-state guard inside the coroutine is defense-in-depth and is
|
||||
// not exercised by this test because StandardTestDispatcher never runs the body.
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
val readyState = viewModel.state.value
|
||||
assertIs<FirmwareUpdateState.Ready>(readyState)
|
||||
|
||||
// Start prepare — launches a coroutine that suspends on getDisplayName.
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
|
||||
|
||||
// Change state during the suspend — cancelUpdate cancels the prepare job and resets state.
|
||||
viewModel.cancelUpdate()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Pending selection should NOT be set.
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
// State should NOT be Error.
|
||||
assertFalse(viewModel.state.value is FirmwareUpdateState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile shows error when filename is unavailable`() = runTest {
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Override the @BeforeTest stub: getDisplayName returns null → resolve cannot continue.
|
||||
everySuspend { fileHandler.getDisplayName(any()) } returns null
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertIs<FirmwareUpdateState.Error>(state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onCleared cleans up pending local firmware artifact`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Prepare a bundle file so pendingLocalFirmwareArtifact is set. nrf52 + BLE expects an
|
||||
// .ota.zip payload, so the extracted artifact must match that extension or
|
||||
// validateExtractedLocalFirmware rejects it and pending is never populated.
|
||||
val extractedArtifact =
|
||||
FirmwareArtifact(
|
||||
uri = CommonUri.parse("file:///tmp/extracted-firmware-ota.zip"),
|
||||
fileName = "firmware-tbeam-2.8.0-ota.zip",
|
||||
isTemporary = true,
|
||||
)
|
||||
everySuspend { fileHandler.extractFirmware(any(), any(), any(), any()) } returns extractedArtifact
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-nrf52840-2.8.0.zip"))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify pending was set (artifact extracted from bundle).
|
||||
assertNotNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
|
||||
// Trigger onCleared via the idiomatic AndroidX ViewModelStore pattern.
|
||||
val store = ViewModelStore()
|
||||
store.put("firmwareUpdateViewModel", viewModel)
|
||||
store.clear()
|
||||
|
||||
// applicationScope uses the same testDispatcher, so advanceUntilIdle flushes the
|
||||
// ATOMIC + NonCancellable cleanup coroutine.
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify the extracted artifact was cleaned up.
|
||||
verifySuspend(atLeast(1)) { fileHandler.deleteFile(extractedArtifact) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmLocalFirmwareFile is no-op when pending selection was cancelled`() = runTest {
|
||||
// After cancelUpdate clears the pending selection, confirmLocalFirmwareFile should
|
||||
// read null and return immediately — no error surfaced, no update started.
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Prepare a valid file so pending is set (nrf52 + BLE expects .ota.zip).
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
|
||||
advanceUntilIdle()
|
||||
assertNotNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
|
||||
// State changes away from Ready before user confirms (cancelUpdate → Idle).
|
||||
viewModel.cancelUpdate()
|
||||
advanceUntilIdle()
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
|
||||
// Confirm should be a no-op: pending was cleared by cancelUpdate.
|
||||
viewModel.confirmLocalFirmwareFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
assertFalse(viewModel.state.value is FirmwareUpdateState.Error)
|
||||
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepareLocalFirmwareFile shows error for Unknown update method`() = runTest {
|
||||
// TCP + nRF52 (non-ESP32) resolves to FirmwareUpdateMethod.Unknown — local files are
|
||||
// not supported there; validateLocalFirmwareFileName returns UnsupportedUpdateMethod
|
||||
// and localFirmwarePayloadExtension returns null (no bundle extraction attempted).
|
||||
every { radioPrefs.devAddr } returns MutableStateFlow("t192.168.1.100")
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertIs<FirmwareUpdateState.Ready>(state)
|
||||
assertIs<FirmwareUpdateMethod.Unknown>(state.updateMethod)
|
||||
|
||||
viewModel.prepareLocalFirmwareFile(firmwareUri("firmware-tbeam-2.8.0-ota.zip"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
|
||||
assertNull(viewModel.pendingLocalFirmwareFile.value)
|
||||
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user