@@ -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
115118internal 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+ }
0 commit comments