Skip to content

Commit 8bfee2a

Browse files
authored
feat(firmware): Confirm local firmware files before flashing (#6083)
1 parent 8662a27 commit 8bfee2a

11 files changed

Lines changed: 1330 additions & 73 deletions

File tree

.skills/compose-ui/strings-index.txt

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,9 +615,14 @@
615615
<string name="firmware_too_old">Firmware update required.</string>
616616
<string name="firmware_update_almost_there">Almost there...</string>
617617
<string name="firmware_update_alpha">Alpha</string>
618+
<string name="firmware_update_archive_missing_target">No firmware for %2$s (%3$s) was found in \"%1$s\".</string>
618619
<string name="firmware_update_battery_low">Battery too low (%1$d%). Please charge your device before updating.</string>
619620
<string name="firmware_update_checking">Checking for updates...</string>
621+
<string name="firmware_update_confirm_file_button">Start Flashing</string>
622+
<string name="firmware_update_confirm_file_message">Review the full selected filename before flashing. Flashing starts only after you confirm.</string>
623+
<string name="firmware_update_confirm_file_title">Confirm Firmware File</string>
620624
<string name="firmware_update_connecting_attempt">Connecting to device (attempt %1$d/%2$d)...</string>
625+
<string name="firmware_update_context_changed">Device connection changed while confirming. Please reselect the firmware file.</string>
621626
<string name="firmware_update_copying">High-five! Wait, copying firmware...</string>
622627
<string name="firmware_update_currently_installed">Currently Installed: %1$s</string>
623628
<string name="firmware_update_device">Device: %1$s</string>
@@ -633,23 +638,29 @@
633638
<string name="firmware_update_error">Error: %1$s</string>
634639
<string name="firmware_update_extracting">Extracting firmware...</string>
635640
<string name="firmware_update_failed">Update failed</string>
641+
<string name="firmware_update_filename_unavailable">Could not read the selected file name. Choose a local firmware file with a visible filename.</string>
636642
<string name="firmware_update_flashing">Flashing device, please wait...</string>
637643
<string name="firmware_update_hang_tight">Hang tight, we are working on it...</string>
638644
<string name="firmware_update_hash_rejected">Firmware hash rejected. Device may require hash provisioning or bootloader update.</string>
645+
<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>
639646
<string name="firmware_update_keep_device_close">Keep your device close to your phone.</string>
640647
<string name="firmware_update_latest">Update To: %1$s</string>
641648
<string name="firmware_update_local_file">Local File</string>
642649
<string name="firmware_update_method_ble">BLE OTA</string>
643650
<string name="firmware_update_method_detail">Update via %1$s</string>
644651
<string name="firmware_update_method_usb">USB File Transfer</string>
645652
<string name="firmware_update_method_wifi">WiFi OTA</string>
653+
<string name="firmware_update_missing_target">Firmware target information is unavailable for this device.</string>
646654
<string name="firmware_update_no_device">No device connected</string>
647655
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
648656
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
649657
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
650658
<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>
651659
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
652660
<string name="firmware_update_release_notes">Release Notes</string>
661+
<string name="firmware_update_requires_bin">%1$s requires a target-matching .bin file.</string>
662+
<string name="firmware_update_requires_ota_zip">%1$s requires a target-matching OTA .zip file.</string>
663+
<string name="firmware_update_requires_uf2">%1$s requires a target-matching .uf2 file.</string>
653664
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
654665
<string name="firmware_update_retry">Retry</string>
655666
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device's DFU drive.</string>
@@ -667,6 +678,7 @@
667678
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
668679
<string name="firmware_update_unknown_release">Unknown remote release</string>
669680
<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>
681+
<string name="firmware_update_unsupported_update_method">Local firmware files are not supported for this update method.</string>
670682
<string name="firmware_update_uploading">Uploading firmware...</string>
671683
<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>
672684
<string name="firmware_update_usb_failed">USB Update failed</string>

feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.meshtastic.feature.firmware
1818

1919
import android.content.Context
20+
import android.provider.OpenableColumns
2021
import co.touchlab.kermit.Logger
2122
import com.eygraber.uri.toAndroidUri
2223
import io.ktor.client.HttpClient
@@ -134,7 +135,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
134135
preferredFilename: String?,
135136
): FirmwareArtifact? = withContext(ioDispatcher) {
136137
val localZipFile = zipFile.toLocalFileOrNull() ?: return@withContext null
137-
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
138+
val target = hardware.effectiveTarget
138139
if (target.isEmpty() && preferredFilename == null) return@withContext null
139140

140141
val targetLowerCase = target.lowercase()
@@ -169,6 +170,8 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
169170
entry = zipInput.nextEntry
170171
}
171172
}
173+
// Prefer the shortest matching entry name — official release bundles contain one
174+
// matching firmware per target; the heuristic picks the canonical name if multiple match.
172175
matchingEntries.minByOrNull { it.first.name.length }?.second?.toFirmwareArtifact()
173176
}
174177

@@ -178,7 +181,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
178181
fileExtension: String,
179182
preferredFilename: String?,
180183
): FirmwareArtifact? = withContext(ioDispatcher) {
181-
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
184+
val target = hardware.effectiveTarget
182185
if (target.isEmpty() && preferredFilename == null) return@withContext null
183186

184187
val targetLowerCase = target.lowercase()
@@ -255,6 +258,37 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
255258
tempFile.toFirmwareArtifact()
256259
}
257260

261+
override suspend fun getDisplayName(uri: CommonUri): String? = withContext(ioDispatcher) {
262+
val platformUri = uri.toAndroidUri()
263+
if (platformUri.scheme == "content") {
264+
// query() can throw SecurityException (revoked permission) or IllegalArgumentException
265+
// (malformed URI) even after the scheme guard; fall through to the file-scheme branch on failure.
266+
runCatching {
267+
context.contentResolver.query(
268+
platformUri,
269+
arrayOf(OpenableColumns.DISPLAY_NAME),
270+
null,
271+
null,
272+
null,
273+
)
274+
}
275+
.onFailure { e -> Logger.w(e) { "Failed to query display name from content provider" } }
276+
.getOrNull()
277+
?.use { cursor ->
278+
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
279+
if (nameIndex >= 0 && cursor.moveToFirst()) {
280+
cursor
281+
.getString(nameIndex)
282+
?.takeIf { it.isNotBlank() }
283+
?.let {
284+
return@withContext it
285+
}
286+
}
287+
}
288+
}
289+
if (platformUri.scheme == "file") uri.pathSegments.lastOrNull()?.takeIf { it.isNotBlank() } else null
290+
}
291+
258292
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
259293
withContext(ioDispatcher) {
260294
val entries = mutableMapOf<String, ByteArray>()

feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt

Lines changed: 183 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ interface FirmwareFileHandler {
6464
*/
6565
suspend fun importFromUri(uri: CommonUri): FirmwareArtifact?
6666

67+
/** Resolve a user-visible display filename for [uri], or `null` when the provider does not expose one. */
68+
suspend fun getDisplayName(uri: CommonUri): String?
69+
6770
/** Copy [source] to the platform URI [destinationUri], returning the number of bytes written. */
6871
suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long
6972

@@ -111,18 +114,192 @@ interface FirmwareFileHandler {
111114
* Check whether [filename] is a valid firmware binary for [target] with the expected [fileExtension]. Excludes
112115
* non-firmware binaries that share the same extension (e.g. `littlefs-*`, `bleota*`).
113116
*/
114-
@Suppress("ComplexCondition")
117+
@Suppress("ComplexCondition") // excluded-binary + target/extension guards collapsed to one early-out
115118
internal fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
116119
if (
117120
filename.startsWith("littlefs-") ||
118121
filename.startsWith("bleota") ||
119122
filename.startsWith("mt-") ||
120-
filename.contains(".factory.")
123+
filename.contains(".factory.") ||
124+
target.isBlank() ||
125+
!filename.endsWith(fileExtension)
121126
) {
122127
return false
123128
}
124-
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_.].*")
125-
return filename.endsWith(fileExtension) &&
126-
filename.contains(target) &&
127-
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
129+
130+
val targetToken = Regex.escape(target)
131+
val extensionToken = Regex.escape(fileExtension)
132+
val targetPattern = Regex("(^|.*[\\-_])$targetToken(([\\-_.](v?\\d|firmware)).*$extensionToken$|$extensionToken$)")
133+
return targetPattern.matches(filename)
134+
}
135+
136+
data class PendingLocalFirmwareFile(
137+
val uri: CommonUri,
138+
val fileName: String,
139+
val deviceName: String,
140+
val platformioTarget: String,
141+
val updateMethod: FirmwareUpdateMethod,
142+
val address: String,
143+
)
144+
145+
/** pioEnv-aware target: falls back to [DeviceHardware.hwModelSlug] when [DeviceHardware.platformioTarget] is unset. */
146+
internal val DeviceHardware.effectiveTarget: String
147+
get() = platformioTarget.ifEmpty { hwModelSlug }
148+
149+
internal fun validatePendingLocalFirmwareFile(
150+
pendingFile: PendingLocalFirmwareFile,
151+
currentState: FirmwareUpdateState.Ready,
152+
): LocalFirmwareFileValidation {
153+
val currentTarget = currentState.deviceHardware.effectiveTarget
154+
return when {
155+
// The user selected the file under a different connection (method or address) than the
156+
// one active now — the file may still match the target, but the confirmation context is stale.
157+
currentState.updateMethod != pendingFile.updateMethod || currentState.address != pendingFile.address ->
158+
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.ConfirmationContextChanged)
159+
160+
currentTarget != pendingFile.platformioTarget ->
161+
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.TargetMismatch)
162+
163+
// Context matches — revalidate the filename against the current method before flashing.
164+
else ->
165+
validateLocalFirmwareFileName(pendingFile.fileName, currentState.deviceHardware, currentState.updateMethod)
166+
}
167+
}
168+
169+
@Suppress("ReturnCount") // two guard-clause early returns + trailing when; refactor would harm clarity
170+
internal fun preferredLocalFirmwareArchiveFilenames(
171+
archiveFileName: String,
172+
hardware: DeviceHardware,
173+
updateMethod: FirmwareUpdateMethod,
174+
): List<String> {
175+
val target = hardware.effectiveTarget.takeIf { it.isNotBlank() } ?: return emptyList()
176+
val version = extractFirmwareVersion(archiveFileName) ?: return emptyList()
177+
return when (updateMethod) {
178+
FirmwareUpdateMethod.Ble ->
179+
if (hardware.isEsp32Arc) {
180+
listOf("firmware-$target-$version-update.bin", "firmware-$target-$version.bin")
181+
} else {
182+
listOf("firmware-$target-$version-ota.zip")
183+
}
184+
185+
FirmwareUpdateMethod.Wifi -> listOf("firmware-$target-$version-update.bin", "firmware-$target-$version.bin")
186+
187+
FirmwareUpdateMethod.Usb -> listOf("firmware-$target-$version.uf2")
188+
189+
FirmwareUpdateMethod.Unknown -> emptyList()
190+
}
191+
}
192+
193+
private fun extractFirmwareVersion(fileName: String): String? {
194+
val baseName = fileName.substringAfterLast('/').substringAfterLast('\\')
195+
return FIRMWARE_VERSION_PATTERN.find(baseName)?.groupValues?.getOrNull(1)
196+
}
197+
198+
private val FIRMWARE_VERSION_PATTERN = Regex("""(?:^|[-_])v?(\d+(?:\.[0-9A-Za-z]+)+)(?=(?:[-_][A-Za-z]+)?\.[^.]+$)""")
199+
200+
internal sealed interface LocalFirmwareFileValidation {
201+
data object Valid : LocalFirmwareFileValidation
202+
203+
data class Invalid(val reason: LocalFirmwareFileValidationReason) : LocalFirmwareFileValidation
204+
}
205+
206+
internal enum class LocalFirmwareFileValidationReason {
207+
MissingTarget,
208+
MissingArchiveFirmware,
209+
RequiresOtaZip,
210+
RequiresBin,
211+
RequiresUf2,
212+
TargetMismatch,
213+
ConfirmationContextChanged,
214+
UnsupportedUpdateMethod,
215+
}
216+
217+
internal fun validateLocalFirmwareFileName(
218+
fileName: String,
219+
hardware: DeviceHardware,
220+
updateMethod: FirmwareUpdateMethod,
221+
): LocalFirmwareFileValidation {
222+
val normalizedFileName = fileName.substringAfterLast('/').substringAfterLast('\\').lowercase()
223+
val target = hardware.effectiveTarget.lowercase()
224+
if (target.isBlank()) {
225+
return LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.MissingTarget)
226+
}
227+
228+
return when (updateMethod) {
229+
FirmwareUpdateMethod.Ble ->
230+
if (hardware.isEsp32Arc) {
231+
validateEsp32LocalFirmware(normalizedFileName, target)
232+
} else {
233+
validateNrf52LocalFirmware(normalizedFileName, target)
234+
}
235+
236+
FirmwareUpdateMethod.Wifi -> validateEsp32LocalFirmware(normalizedFileName, target)
237+
238+
FirmwareUpdateMethod.Usb -> validateUsbLocalFirmware(normalizedFileName, target)
239+
240+
FirmwareUpdateMethod.Unknown ->
241+
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.UnsupportedUpdateMethod)
242+
}
243+
}
244+
245+
/**
246+
* Returns the firmware payload file extension this platform's update handler expects to receive.
247+
*
248+
* nRF52 BLE consumes the Nordic DFU `.zip` package directly via [SecureDfuHandler]; ESP32 (BLE or WiFi) streams a raw
249+
* `.bin`; USB copies a `.uf2` to the mass-storage drive. Returns `null` for [FirmwareUpdateMethod.Unknown], which does
250+
* not support local files.
251+
*/
252+
internal fun localFirmwarePayloadExtension(hardware: DeviceHardware, updateMethod: FirmwareUpdateMethod): String? =
253+
when (updateMethod) {
254+
FirmwareUpdateMethod.Ble -> if (hardware.isEsp32Arc) ".bin" else ".zip"
255+
FirmwareUpdateMethod.Wifi -> ".bin"
256+
FirmwareUpdateMethod.Usb -> ".uf2"
257+
FirmwareUpdateMethod.Unknown -> null
258+
}
259+
260+
private fun validateNrf52LocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
261+
validateSuffixedLocalFirmware(
262+
fileName = fileName,
263+
target = target,
264+
requiredSuffix = "-ota.zip",
265+
fileExtension = ".zip",
266+
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresOtaZip,
267+
)
268+
269+
private fun validateEsp32LocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
270+
validateSuffixedLocalFirmware(
271+
fileName = fileName,
272+
target = target,
273+
requiredSuffix = ".bin",
274+
fileExtension = ".bin",
275+
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresBin,
276+
)
277+
278+
private fun validateUsbLocalFirmware(fileName: String, target: String): LocalFirmwareFileValidation =
279+
validateSuffixedLocalFirmware(
280+
fileName = fileName,
281+
target = target,
282+
requiredSuffix = ".uf2",
283+
fileExtension = ".uf2",
284+
missingSuffixReason = LocalFirmwareFileValidationReason.RequiresUf2,
285+
)
286+
287+
private fun validateSuffixedLocalFirmware(
288+
fileName: String,
289+
target: String,
290+
requiredSuffix: String,
291+
fileExtension: String,
292+
missingSuffixReason: LocalFirmwareFileValidationReason,
293+
): LocalFirmwareFileValidation {
294+
if (!fileName.endsWith(requiredSuffix)) {
295+
return LocalFirmwareFileValidation.Invalid(missingSuffixReason)
296+
}
297+
return validateTargetMatch(fileName, target, fileExtension)
128298
}
299+
300+
private fun validateTargetMatch(fileName: String, target: String, fileExtension: String): LocalFirmwareFileValidation =
301+
if (isValidFirmwareFile(fileName, target, fileExtension)) {
302+
LocalFirmwareFileValidation.Valid
303+
} else {
304+
LocalFirmwareFileValidation.Invalid(LocalFirmwareFileValidationReason.TargetMismatch)
305+
}

feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ data class FirmwareUpdateActions(
2323
val onStartUpdate: () -> Unit,
2424
val onPickFile: () -> Unit,
2525
val onSaveFile: (String) -> Unit,
26+
val onConfirmLocalFile: () -> Unit,
27+
val onDismissLocalFile: () -> Unit,
2628
val onRetry: () -> Unit,
2729
val onCancel: () -> Unit,
2830
val onDone: () -> Unit,

0 commit comments

Comments
 (0)