feat(firmware): Implement USB DFU updates for supported devices (#3901)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-12-06 06:36:54 -06:00 committed by GitHub
parent f322eb31a0
commit 499ed58311
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 860 additions and 483 deletions

View file

@ -1459,6 +1459,7 @@ class MeshService : Service() {
private fun onConnectionChanged(c: ConnectionState) {
if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
Timber.d("onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c")
serviceRepository.setConnectionTransport(currentTransport())
// Cancel any existing timeouts
sleepTimeout?.cancel()
@ -2604,6 +2605,10 @@ class MeshService : Service() {
)
}
override fun rebootToDfu() {
packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { enterDfuModeRequest = true })
}
override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions {
packetHandler.sendToRadio(
newMeshPacketTo(destNum).buildAdminPacket(id = requestId) { factoryResetDevice = 1 },

View file

@ -27,6 +27,16 @@ interface RadioPrefs {
var devAddr: String?
}
fun RadioPrefs.isBle() = devAddr?.startsWith("x") == true
fun RadioPrefs.isSerial() = devAddr?.startsWith("s") == true
fun RadioPrefs.isMock() = devAddr?.startsWith("m") == true
fun RadioPrefs.isTcp() = devAddr?.startsWith("t") == true
fun RadioPrefs.isNoop() = devAddr?.startsWith("n") == true
@Singleton
class RadioPrefsImpl @Inject constructor(@RadioSharedPreferences prefs: SharedPreferences) : RadioPrefs {
override var devAddr: String? by NullableStringPrefDelegate(prefs, "devAddr2", null)

View file

@ -135,6 +135,9 @@ interface IMeshService {
/// Send FactoryReset admin packet to nodeNum
void requestFactoryReset(in int requestId, in int destNum);
/// Send reboot to DFU admin packet
void rebootToDfu();
/// Send NodedbReset admin packet to nodeNum
void requestNodedbReset(in int requestId, in int destNum, in boolean preserveFavorites);
@ -173,4 +176,4 @@ interface IMeshService {
/// Request device connection status from the radio
void getDeviceConnectionStatus(in int requestId, in int destNum);
}
}

View file

@ -49,6 +49,14 @@ class ServiceRepository @Inject constructor() {
_connectionState.value = connectionState
}
private val _connectionTransport: MutableStateFlow<String> = MutableStateFlow("Unknown")
val connectionTransport: StateFlow<String>
get() = _connectionTransport
fun setConnectionTransport(connectionTransport: String) {
_connectionTransport.value = connectionTransport
}
private val _clientNotification = MutableStateFlow<MeshProtos.ClientNotification?>(null)
val clientNotification: StateFlow<MeshProtos.ClientNotification?>
get() = _clientNotification

View file

@ -976,10 +976,10 @@
<string name="firmware_update_title">Firmware Update</string>
<string name="firmware_update_checking">Checking for updates...</string>
<string name="firmware_update_device">Device: %1$s</string>
<string name="firmware_update_currently_installed">Currently Installed: %1$s</string>
<string name="firmware_update_latest">Latest Release: %1$s</string>
<string name="firmware_update_stable">Stable</string>
<string name="firmware_update_alpha">Alpha</string>
<string name="firmware_update_button">Update Firmware</string>
<string name="firmware_update_disconnect_warning">Note: This will temporarily disconnect your device during the update.</string>
<string name="firmware_update_downloading">Downloading firmware... %1$d%</string>
<string name="firmware_update_error">Error: %1$s</string>
@ -989,7 +989,7 @@
<string name="firmware_update_starting_dfu">Starting DFU...</string>
<string name="firmware_update_updating">Updating... %1$s%</string>
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
<string name="firmware_update_invalid_address">Connected device is not a BLE device or address is unknown (%1$s). DFU requires BLE.</string>
<string name="firmware_update_invalid_address">Connected device is not a valid BLE device or address is unknown (%1$s).</string>
<string name="firmware_update_no_device">No device connected</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
<string name="firmware_update_extracting">Extracting firmware...</string>
@ -1006,4 +1006,14 @@
<string name="firmware_update_disclaimer_text">You are about to flash new firmware to your device. This process carries risks.\n\n• Ensure your device is charged.\n• Keep the device close to your phone.\n• Do not close the app during the update.\n\nVerify you have selected the correct firmware for your hardware.</string>
<string name="firmware_update_disclaimer_chirpy_says">Chirpy says, "Keep your ladder handy!"</string>
<string name="chirpy">Chirpy</string>
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
<string name="firmware_update_waiting_for_device">Waiting for DFU device...</string>
<string name="firmware_update_copying">Copying firmware...</string>
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device&apos;s DFU drive.</string>
<string name="firmware_update_flashing">Flashing device, please wait...</string>
<string name="firmware_update_method_usb">USB File Transfer</string>
<string name="firmware_update_method_ble">BLE OTA</string>
<string name="firmware_update_method_detail">Update via %1$s</string>
<string name="firmware_update_usb_instruction_title">Select DFU USB Drive</string>
<string name="firmware_update_usb_instruction_text">Your device has rebooted into DFU mode and should appear as a USB drive (e.g., RAK4631).\n\nWhen the file picker opens, please select the root of that drive to save the firmware file.</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>TooGenericExceptionCaught:FirmwareUpdateViewModel.kt$FirmwareUpdateViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:UpdateHandler.kt$FirmwareRetriever$e: Exception</ID>
<ID>TooGenericExceptionCaught:UpdateHandler.kt$OtaUpdateHandler$e: Exception</ID>
<ID>TooGenericExceptionCaught:UpdateHandler.kt$UsbUpdateHandler$e: Exception</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,201 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.meshtastic.core.model.DeviceHardware
import timber.log.Timber
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import javax.inject.Inject
private const val DOWNLOAD_BUFFER_SIZE = 8192
/**
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
* extracting specific files from Zip archives.
*/
class FirmwareFileHandler
@Inject
constructor(
@ApplicationContext private val context: Context,
private val client: OkHttpClient,
) {
private val tempDir = File(context.cacheDir, "firmware_update")
fun cleanupAllTemporaryFiles() {
runCatching {
if (tempDir.exists()) {
tempDir.deleteRecursively()
}
tempDir.mkdirs()
}
.onFailure { e -> Timber.w(e, "Failed to cleanup temp directory") }
}
suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).head().build()
try {
client.newCall(request).execute().use { response -> response.isSuccessful }
} catch (e: IOException) {
Timber.w(e, "Failed to check URL existence: $url")
false
}
}
suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): File? =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
val response =
try {
client.newCall(request).execute()
} catch (e: IOException) {
Timber.w(e, "Download failed for $url")
return@withContext null
}
if (!response.isSuccessful) {
Timber.w("Download failed: ${response.code} for $url")
return@withContext null
}
val body = response.body ?: return@withContext null
val contentLength = body.contentLength()
if (!tempDir.exists()) tempDir.mkdirs()
val targetFile = File(tempDir, fileName)
body.byteStream().use { input ->
FileOutputStream(targetFile).use { output ->
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = 0L
while (input.read(buffer).also { bytesRead = it } != -1) {
if (!isActive) throw CancellationException("Download cancelled")
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
onProgress(totalBytesRead.toFloat() / contentLength)
}
}
if (contentLength != -1L && totalBytesRead != contentLength) {
throw IOException("Incomplete download: expected $contentLength bytes, got $totalBytesRead")
}
}
}
targetFile
}
suspend fun extractFirmware(zipFile: File, hardware: DeviceHardware, fileExtension: String): File? =
withContext(Dispatchers.IO) {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty()) return@withContext null
val targetLowerCase = target.lowercase()
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
if (!tempDir.exists()) tempDir.mkdirs()
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)) {
val outFile = File(tempDir, File(name).name)
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
matchingEntries.add(entry to outFile)
}
entry = zipInput.nextEntry
}
}
matchingEntries.minByOrNull { it.first.name.length }?.second
}
suspend fun extractFirmware(uri: Uri, hardware: DeviceHardware, fileExtension: String): File? =
withContext(Dispatchers.IO) {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty()) return@withContext null
val targetLowerCase = target.lowercase()
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
if (!tempDir.exists()) tempDir.mkdirs()
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null
ZipInputStream(inputStream).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)) {
val outFile = File(tempDir, File(name).name)
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
matchingEntries.add(entry to outFile)
}
entry = zipInput.nextEntry
}
}
} catch (e: IOException) {
Timber.w(e, "Failed to extract firmware from URI")
return@withContext null
}
matchingEntries.minByOrNull { it.first.name.length }?.second
}
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*")
return filename.endsWith(fileExtension) &&
filename.contains(target) &&
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
}
suspend fun copyFileToUri(sourceFile: File, destinationUri: Uri) = withContext(Dispatchers.IO) {
val inputStream = FileInputStream(sourceFile)
val outputStream =
context.contentResolver.openOutputStream(destinationUri)
?: throw IOException("Cannot open content URI for writing")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
suspend fun copyUriToUri(sourceUri: Uri, destinationUri: Uri) = withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(sourceUri) ?: throw IOException("Cannot open source URI")
val outputStream =
context.contentResolver.openOutputStream(destinationUri)
?: throw IOException("Cannot open destination URI")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
}
}

View file

@ -53,6 +53,8 @@ import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@ -89,6 +91,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import coil3.compose.AsyncImage
@ -105,8 +108,8 @@ import org.meshtastic.core.strings.chirpy
import org.meshtastic.core.strings.dont_show_again_for_device
import org.meshtastic.core.strings.firmware_update_almost_there
import org.meshtastic.core.strings.firmware_update_alpha
import org.meshtastic.core.strings.firmware_update_button
import org.meshtastic.core.strings.firmware_update_checking
import org.meshtastic.core.strings.firmware_update_currently_installed
import org.meshtastic.core.strings.firmware_update_device
import org.meshtastic.core.strings.firmware_update_disclaimer_chirpy_says
import org.meshtastic.core.strings.firmware_update_disclaimer_text
@ -119,8 +122,10 @@ import org.meshtastic.core.strings.firmware_update_error
import org.meshtastic.core.strings.firmware_update_hang_tight
import org.meshtastic.core.strings.firmware_update_keep_device_close
import org.meshtastic.core.strings.firmware_update_latest
import org.meshtastic.core.strings.firmware_update_method_detail
import org.meshtastic.core.strings.firmware_update_rak4631_bootloader_hint
import org.meshtastic.core.strings.firmware_update_retry
import org.meshtastic.core.strings.firmware_update_save_dfu_file
import org.meshtastic.core.strings.firmware_update_select_file
import org.meshtastic.core.strings.firmware_update_stable
import org.meshtastic.core.strings.firmware_update_success
@ -128,8 +133,12 @@ import org.meshtastic.core.strings.firmware_update_taking_a_while
import org.meshtastic.core.strings.firmware_update_title
import org.meshtastic.core.strings.firmware_update_unknown_release
import org.meshtastic.core.strings.firmware_update_usb_bootloader_warning
import org.meshtastic.core.strings.firmware_update_usb_instruction_text
import org.meshtastic.core.strings.firmware_update_usb_instruction_title
import org.meshtastic.core.strings.i_know_what_i_m_doing
import org.meshtastic.core.strings.learn_more
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.save
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -141,10 +150,21 @@ fun FirmwareUpdateScreen(
val state by viewModel.state.collectAsState()
val selectedReleaseType by viewModel.selectedReleaseType.collectAsState()
val launcher =
val getZipFileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let { viewModel.startUpdateFromFile(it) }
}
val getUf2FileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let { viewModel.startUpdateFromFile(it) }
}
val saveFileLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri: Uri? ->
uri?.let { viewModel.saveDfuFile(it) }
}
val shouldKeepScreenOn = shouldKeepFirmwareScreenOn(state)
@ -157,7 +177,16 @@ fun FirmwareUpdateScreen(
selectedReleaseType = selectedReleaseType,
onReleaseTypeSelect = viewModel::setReleaseType,
onStartUpdate = viewModel::startUpdate,
onPickFile = { launcher.launch("application/zip") },
onPickFile = {
if (state is FirmwareUpdateState.Ready) {
if ((state as FirmwareUpdateState.Ready).updateMethod is FirmwareUpdateMethod.Ble) {
getZipFileLauncher.launch("application/zip")
} else if ((state as FirmwareUpdateState.Ready).updateMethod is FirmwareUpdateMethod.Usb) {
getUf2FileLauncher.launch("*/*")
}
}
},
onSaveFile = { fileName -> saveFileLauncher.launch(fileName) },
onRetry = viewModel::checkForUpdates,
onDone = { navController.navigateUp() },
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
@ -172,6 +201,7 @@ private fun FirmwareUpdateScaffold(
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
onStartUpdate: () -> Unit,
onPickFile: () -> Unit,
onSaveFile: (String) -> Unit,
onRetry: () -> Unit,
onDone: () -> Unit,
onDismissBootloaderWarning: () -> Unit,
@ -191,33 +221,17 @@ private fun FirmwareUpdateScaffold(
},
) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) {
AnimatedContent(
targetState = state,
contentKey = { targetState ->
when (targetState) {
is FirmwareUpdateState.Idle -> "Idle"
is FirmwareUpdateState.Checking -> "Checking"
is FirmwareUpdateState.Ready -> "Ready"
is FirmwareUpdateState.Downloading -> "Downloading"
is FirmwareUpdateState.Processing -> "Processing"
is FirmwareUpdateState.Updating -> "Updating"
is FirmwareUpdateState.Error -> "Error"
is FirmwareUpdateState.Success -> "Success"
}
},
label = "FirmwareState",
) { targetState ->
FirmwareUpdateContent(
state = targetState,
selectedReleaseType = selectedReleaseType,
onReleaseTypeSelect = onReleaseTypeSelect,
onStartUpdate = onStartUpdate,
onPickFile = onPickFile,
onRetry = onRetry,
onDone = onDone,
onDismissBootloaderWarning = onDismissBootloaderWarning,
)
}
FirmwareUpdateContent(
state = state,
selectedReleaseType = selectedReleaseType,
onReleaseTypeSelect = onReleaseTypeSelect,
onStartUpdate = onStartUpdate,
onPickFile = onPickFile,
onSaveFile = onSaveFile,
onRetry = onRetry,
onDone = onDone,
onDismissBootloaderWarning = onDismissBootloaderWarning,
)
}
}
}
@ -227,6 +241,7 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh
is FirmwareUpdateState.Processing,
is FirmwareUpdateState.Updating,
-> true
else -> false
}
@ -237,6 +252,7 @@ private fun FirmwareUpdateContent(
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
onStartUpdate: () -> Unit,
onPickFile: () -> Unit,
onSaveFile: (String) -> Unit,
onRetry: () -> Unit,
onDone: () -> Unit,
onDismissBootloaderWarning: () -> Unit,
@ -272,8 +288,8 @@ private fun FirmwareUpdateContent(
is FirmwareUpdateState.Processing -> ProcessingState(state.message)
is FirmwareUpdateState.Updating -> UpdatingState(state)
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = onRetry)
is FirmwareUpdateState.Success -> SuccessState(onDone = onDone)
is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, onSaveFile)
}
},
)
@ -303,6 +319,7 @@ private fun ColumnScope.ReadyState(
if (showDisclaimer) {
DisclaimerDialog(
updateMethod = state.updateMethod,
onDismissRequest = { showDisclaimer = false },
onConfirm = {
showDisclaimer = false
@ -311,18 +328,13 @@ private fun ColumnScope.ReadyState(
)
}
DeviceHardwareImage(device, Modifier.size(150.dp))
Spacer(Modifier.height(24.dp))
DeviceInfoCard(device, state.release)
DeviceInfoCard(device, state.release, state.currentFirmwareVersion)
if (state.showBootloaderWarning) {
Spacer(Modifier.height(16.dp))
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = onDismissBootloaderWarning)
}
Spacer(Modifier.height(24.dp))
if (state.release != null) {
ReleaseTypeSelector(selectedReleaseType, onReleaseTypeSelect)
Spacer(Modifier.height(16.dp))
@ -335,9 +347,22 @@ private fun ColumnScope.ReadyState(
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Icon(Icons.Default.SystemUpdate, contentDescription = null)
Icon(
imageVector =
when (state.updateMethod) {
FirmwareUpdateMethod.Ble -> Icons.Rounded.Bluetooth
FirmwareUpdateMethod.Usb -> Icons.Rounded.Usb
else -> Icons.Default.SystemUpdate
},
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.firmware_update_button))
Text(
stringResource(
resource = Res.string.firmware_update_method_detail,
stringResource(state.updateMethod.description),
),
)
}
Spacer(Modifier.height(16.dp))
}
@ -353,27 +378,10 @@ private fun ColumnScope.ReadyState(
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.firmware_update_select_file))
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(Res.string.firmware_update_disconnect_warning),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
@Composable
private fun DisclaimerDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit) {
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissRequest: () -> Unit, onConfirm: () -> Unit) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) },
@ -381,32 +389,23 @@ private fun DisclaimerDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(Res.string.firmware_update_disclaimer_text))
Spacer(modifier = Modifier.height(8.dp))
Card(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
Column(
modifier = Modifier.fillMaxWidth().padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = spacedBy(4.dp),
) {
BasicText(text = "🪜", modifier = Modifier.size(48.dp), autoSize = TextAutoSize.StepBased())
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
.data(org.meshtastic.core.ui.R.drawable.chirpy)
.build(),
contentScale = ContentScale.Fit,
contentDescription = stringResource(Res.string.chirpy),
modifier = Modifier.size(48.dp),
)
}
Text(
text = stringResource(Res.string.firmware_update_disclaimer_chirpy_says),
style = MaterialTheme.typography.labelSmall,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(Res.string.firmware_update_disconnect_warning),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
)
}
if (updateMethod is FirmwareUpdateMethod.Ble) {
Spacer(modifier = Modifier.height(8.dp))
ChirpyCard()
}
}
},
@ -415,6 +414,34 @@ private fun DisclaimerDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit
)
}
@Composable
private fun ChirpyCard() {
Card(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = spacedBy(4.dp),
) {
BasicText(text = "🪜", modifier = Modifier.size(48.dp), autoSize = TextAutoSize.StepBased())
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
.data(org.meshtastic.core.ui.R.drawable.chirpy)
.build(),
contentScale = ContentScale.Fit,
contentDescription = stringResource(Res.string.chirpy),
modifier = Modifier.size(48.dp),
)
}
Text(
text = stringResource(Res.string.firmware_update_disclaimer_chirpy_says),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
@ -460,7 +487,11 @@ private fun ReleaseNotesCard(releaseNotes: String) {
}
@Composable
private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRelease?) {
private fun DeviceInfoCard(
deviceHardware: DeviceHardware,
release: FirmwareRelease?,
currentFirmwareVersion: String? = null,
) {
val target = deviceHardware.hwModelSlug.ifEmpty { deviceHardware.platformioTarget }
ElevatedCard(
@ -468,11 +499,19 @@ private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRele
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(Res.string.firmware_update_device, deviceHardware.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
DeviceHardwareImage(deviceHardware, Modifier.size(80.dp))
Text(
stringResource(Res.string.firmware_update_device, deviceHardware.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(8.dp))
Text(
"Target: $target",
@ -480,9 +519,17 @@ private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRele
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
val releaseTitle = release?.title ?: stringResource(Res.string.firmware_update_unknown_release)
val currentVersion =
stringResource(
Res.string.firmware_update_currently_installed,
currentFirmwareVersion ?: stringResource(Res.string.firmware_update_unknown_release),
)
Text(modifier = Modifier.fillMaxWidth(), text = currentVersion)
Spacer(Modifier.height(4.dp))
val releaseVersion = release?.title ?: stringResource(Res.string.firmware_update_unknown_release)
Text(
stringResource(Res.string.firmware_update_latest, releaseTitle),
modifier = Modifier.fillMaxWidth(),
text = stringResource(Res.string.firmware_update_latest, releaseVersion),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
)
@ -533,7 +580,7 @@ private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDe
runCatching {
val intent =
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse(infoUrl)
data = infoUrl.toUri()
}
context.startActivity(intent)
}
@ -614,6 +661,45 @@ private fun ColumnScope.UpdatingState(state: FirmwareUpdateState.Updating) {
CyclingMessages()
}
@Composable
private fun ColumnScope.AwaitingFileSaveState(
state: FirmwareUpdateState.AwaitingFileSave,
onSaveFile: (String) -> Unit,
) {
var showDialog by remember { mutableStateOf(true) }
if (showDialog) {
AlertDialog(
onDismissRequest = { /* No-op to force user to acknowledge */ },
title = { Text(stringResource(Res.string.firmware_update_usb_instruction_title)) },
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
confirmButton = {
TextButton(
onClick = {
showDialog = false
onSaveFile(state.fileName)
},
) {
Text(stringResource(Res.string.okay))
}
},
)
}
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.firmware_update_save_dfu_file),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
if (!showDialog) {
Spacer(Modifier.height(16.dp))
Button(onClick = { onSaveFile(state.fileName) }) { Text(stringResource(Res.string.save)) }
}
}
private const val CYCLE_DELAY = 4000L
@Composable

View file

@ -17,8 +17,10 @@
package org.meshtastic.feature.firmware
import android.net.Uri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import java.io.File
sealed interface FirmwareUpdateState {
data object Idle : FirmwareUpdateState
@ -30,6 +32,8 @@ sealed interface FirmwareUpdateState {
val deviceHardware: DeviceHardware,
val address: String,
val showBootloaderWarning: Boolean,
val updateMethod: FirmwareUpdateMethod,
val currentFirmwareVersion: String? = null,
) : FirmwareUpdateState
data class Downloading(val progress: Float) : FirmwareUpdateState
@ -41,4 +45,7 @@ sealed interface FirmwareUpdateState {
data class Error(val error: String) : FirmwareUpdateState
data object Success : FirmwareUpdateState
data class AwaitingFileSave(val uf2File: File?, val fileName: String, val sourceUri: Uri? = null) :
FirmwareUpdateState
}

View file

@ -17,8 +17,13 @@
package org.meshtastic.feature.firmware
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbManager
import android.net.Uri
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -33,17 +38,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import no.nordicsemi.android.dfu.DfuProgressListenerAdapter
import no.nordicsemi.android.dfu.DfuServiceInitiator
import no.nordicsemi.android.dfu.DfuServiceListenerHelper
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.core.ConnectionState
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
@ -52,42 +53,35 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_copying
import org.meshtastic.core.strings.firmware_update_extracting
import org.meshtastic.core.strings.firmware_update_failed
import org.meshtastic.core.strings.firmware_update_invalid_address
import org.meshtastic.core.strings.firmware_update_flashing
import org.meshtastic.core.strings.firmware_update_method_ble
import org.meshtastic.core.strings.firmware_update_method_usb
import org.meshtastic.core.strings.firmware_update_no_device
import org.meshtastic.core.strings.firmware_update_not_found_in_release
import org.meshtastic.core.strings.firmware_update_rebooting
import org.meshtastic.core.strings.firmware_update_starting_dfu
import org.meshtastic.core.strings.firmware_update_starting_service
import org.meshtastic.core.strings.firmware_update_unknown_hardware
import org.meshtastic.core.strings.firmware_update_updating
import org.meshtastic.core.strings.unknown
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import javax.inject.Inject
private const val NO_DEVICE_SELECTED = "n"
private const val DFU_RECONNECT_PREFIX = "x"
private const val DOWNLOAD_BUFFER_SIZE = 8192
private const val PERCENT_MAX_VALUE = 100f
private const val SCAN_TIMEOUT = 2000L
private const val PACKETS_BEFORE_PRN = 8
private const val DEVICE_DETACH_TIMEOUT = 30_000L
private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
/**
* ViewModel responsible for managing the firmware update process for Meshtastic devices.
*
* It handles checking for updates, downloading firmware artifacts, extracting compatible firmware, and initiating the
* Device Firmware Update (DFU) process over Bluetooth.
*/
@HiltViewModel
@Suppress("LongParameterList")
class FirmwareUpdateViewModel
@ -96,11 +90,13 @@ constructor(
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val nodeRepository: NodeRepository,
private val centralManager: CentralManager,
client: OkHttpClient,
private val serviceRepository: ServiceRepository,
private val radioPrefs: RadioPrefs,
@ApplicationContext private val context: Context,
private val bootloaderWarningDataSource: BootloaderWarningDataSource,
private val otaUpdateHandler: OtaUpdateHandler,
private val usbUpdateHandler: UsbUpdateHandler,
private val fileHandler: FirmwareFileHandler,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@ -110,16 +106,15 @@ constructor(
val selectedReleaseType: StateFlow<FirmwareReleaseType> = _selectedReleaseType.asStateFlow()
private var updateJob: Job? = null
private val fileHandler = FirmwareFileHandler(context, client)
private var tempFirmwareFile: File? = null
init {
// Cleanup potential leftovers from previous crashes
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
checkForUpdates()
// Start listening to DFU events immediately
viewModelScope.launch { observeDfuProgress() }
// Cleanup potential leftovers
viewModelScope.launch {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
checkForUpdates()
observeDfuProgress()
}
}
override fun onCleared() {
@ -127,45 +122,49 @@ constructor(
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
/** Sets the desired [FirmwareReleaseType] (e.g., ALPHA, STABLE) and triggers a new update check. */
fun setReleaseType(type: FirmwareReleaseType) {
_selectedReleaseType.value = type
checkForUpdates()
}
/**
* Initiates a check for available firmware updates based on the selected release type.
*
* Validates the current device connection and hardware before fetching release information. Updates [state] to
* [FirmwareUpdateState.Checking], then [FirmwareUpdateState.Ready] or [FirmwareUpdateState.Error].
*/
fun checkForUpdates() {
updateJob?.cancel()
updateJob =
viewModelScope.launch {
_state.value = FirmwareUpdateState.Checking
runCatching {
val validationResult = validateDeviceAndConnection()
if (validationResult == null) {
// Validation failed, state is already set to Error inside validateDeviceAndConnection
val ourNode = nodeRepository.ourNodeInfo.value
val address = radioPrefs.devAddr?.drop(1)
if (address == null || ourNode == null) {
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_no_device))
return@launch
}
val (ourNode, _, address) = validationResult
val deviceHardware = getDeviceHardware(ourNode) ?: return@launch
firmwareReleaseRepository.getReleaseFlow(_selectedReleaseType.value).collectLatest { release ->
val dismissed = bootloaderWarningDataSource.isDismissed(address)
_state.value =
FirmwareUpdateState.Ready(
release = release,
deviceHardware = deviceHardware,
address = address,
showBootloaderWarning =
deviceHardware.requiresBootloaderUpgradeForOta == true && !dismissed,
)
getDeviceHardware(ourNode)?.let { deviceHardware ->
firmwareReleaseRepository.getReleaseFlow(
_selectedReleaseType.value,
).collectLatest { release ->
val dismissed = bootloaderWarningDataSource.isDismissed(address)
val firmwareUpdateMethod =
if (radioPrefs.isSerial()) {
FirmwareUpdateMethod.Usb
} else if (radioPrefs.isBle()) {
FirmwareUpdateMethod.Ble
} else {
FirmwareUpdateMethod.Unknown
}
_state.value =
FirmwareUpdateState.Ready(
release = release,
deviceHardware = deviceHardware,
address = address,
showBootloaderWarning =
deviceHardware.requiresBootloaderUpgradeForOta == true &&
!dismissed &&
radioPrefs.isBle(),
updateMethod = firmwareUpdateMethod,
currentFirmwareVersion = ourNode.metadata?.firmwareVersion,
)
}
}
}
.onFailure { e ->
@ -176,107 +175,107 @@ constructor(
}
}
/**
* Starts the firmware update process using the currently identified release.
* 1. Downloads the firmware zip from the release URL.
* 2. Extracts the correct firmware image for the connected device hardware.
* 3. Initiates the DFU process.
*/
@Suppress("TooGenericExceptionCaught")
fun startUpdate() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val release = currentState.release
val hardware = currentState.deviceHardware
val address = currentState.address
if (release == null || !isValidBluetoothAddress(address)) return
val release = currentState.release ?: return
updateJob?.cancel()
updateJob =
viewModelScope.launch {
try {
// 1. Download
_state.value = FirmwareUpdateState.Downloading(0f)
var firmwareFile: File? = null
// Try direct download of the specific device firmware
val version = release.id.removePrefix("v")
// We prefer platformioTarget because it matches the build artifact naming
// convention (lower-case with hyphens).
// hwModelSlug often uses underscores and uppercase
// (e.g. TRACKER_T1000_E vs tracker-t1000-e).
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val filename = "firmware-$target-$version-ota.zip"
val directUrl = "https://meshtastic.github.io/firmware-$version/$filename"
if (fileHandler.checkUrlExists(directUrl)) {
try {
firmwareFile =
fileHandler.downloadFile(directUrl, "firmware_direct.zip") { progress ->
_state.value = FirmwareUpdateState.Downloading(progress)
}
} catch (e: Exception) {
Timber.w(e, "Direct download failed, falling back to release zip")
}
}
if (firmwareFile == null) {
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
val downloadedZip =
fileHandler.downloadFile(zipUrl, "firmware_release.zip") { progress ->
_state.value = FirmwareUpdateState.Downloading(progress)
}
// Note: Current API does not provide checksums, so we rely on content-length
// checks during download and integrity checks during extraction.
// 2. Extract
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_extracting))
val extracted = fileHandler.extractFirmware(downloadedZip, hardware)
if (extracted == null) {
val msg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName)
_state.value = FirmwareUpdateState.Error(msg)
return@launch
}
firmwareFile = extracted
}
tempFirmwareFile = firmwareFile
initiateDfu(address, hardware, firmwareFile!!)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e)
_state.value = FirmwareUpdateState.Error(e.message ?: getString(Res.string.firmware_update_failed))
if (radioPrefs.isSerial()) {
tempFirmwareFile =
usbUpdateHandler.startUpdate(
release = release,
hardware = currentState.deviceHardware,
updateState = { _state.value = it },
rebootingMsg = getString(Res.string.firmware_update_rebooting),
)
} else if (radioPrefs.isBle()) {
tempFirmwareFile =
otaUpdateHandler.startUpdate(
release = release,
hardware = currentState.deviceHardware,
address = currentState.address,
updateState = { _state.value = it },
notFoundMsg =
getString(
Res.string.firmware_update_not_found_in_release,
currentState.deviceHardware.displayName,
),
startingMsg = getString(Res.string.firmware_update_starting_service),
)
}
}
}
/**
* Starts a firmware update using a local file provided via [Uri].
*
* Copies the content to a temporary file and initiates the DFU process.
*/
@Suppress("TooGenericExceptionCaught")
fun saveDfuFile(uri: Uri) {
val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return
val firmwareFile = currentState.uf2File
val sourceUri = currentState.sourceUri
viewModelScope.launch {
try {
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_copying))
if (firmwareFile != null) {
fileHandler.copyFileToUri(firmwareFile, uri)
} else if (sourceUri != null) {
fileHandler.copyUriToUri(sourceUri, uri)
}
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_flashing))
withTimeoutOrNull(DEVICE_DETACH_TIMEOUT) { waitForDeviceDetach(context).first() }
?: Timber.w("Timed out waiting for device to detach, assuming success")
_state.value = FirmwareUpdateState.Success
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e)
_state.value = FirmwareUpdateState.Error(e.message ?: getString(Res.string.firmware_update_failed))
} finally {
cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
}
fun startUpdateFromFile(uri: Uri) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val hardware = currentState.deviceHardware
val address = currentState.address
if (!isValidBluetoothAddress(address)) return
if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) {
return
}
updateJob?.cancel()
updateJob =
viewModelScope.launch {
try {
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_extracting))
val localFile = fileHandler.copyUriToFile(uri)
tempFirmwareFile = localFile
val extension = if (currentState.updateMethod is FirmwareUpdateMethod.Ble) ".zip" else ".uf2"
val extractedFile = fileHandler.extractFirmware(uri, currentState.deviceHardware, extension)
initiateDfu(address, hardware, localFile)
tempFirmwareFile = extractedFile
val firmwareUri = if (extractedFile != null) Uri.fromFile(extractedFile) else uri
if (currentState.updateMethod is FirmwareUpdateMethod.Ble) {
otaUpdateHandler.startUpdate(
release =
FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""),
hardware = currentState.deviceHardware,
address = currentState.address,
updateState = { _state.value = it },
notFoundMsg = "File not found",
startingMsg = getString(Res.string.firmware_update_starting_service),
firmwareUri = firmwareUri,
)
} else if (currentState.updateMethod is FirmwareUpdateMethod.Usb) {
usbUpdateHandler.startUpdate(
release =
FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""),
hardware = currentState.deviceHardware,
updateState = { _state.value = it },
rebootingMsg = getString(Res.string.firmware_update_rebooting),
firmwareUri = firmwareUri,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@ -286,120 +285,50 @@ constructor(
}
}
/** Persists dismissal of the bootloader warning for the current device and updates state accordingly. */
fun dismissBootloaderWarningForCurrentDevice() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val address = currentState.address
viewModelScope.launch {
runCatching { bootloaderWarningDataSource.dismiss(address) }
.onFailure { e ->
Timber.w(e, "Failed to persist bootloader warning dismissal for address=%s", address)
}
bootloaderWarningDataSource.dismiss(currentState.address)
_state.value = currentState.copy(showBootloaderWarning = false)
}
}
/**
* Configures the DFU service and starts the update.
*
* @param address The Bluetooth address of the target device.
* @param deviceHardware The hardware definition of the target device.
* @param firmwareFile The local file containing the firmware image.
*/
private fun initiateDfu(address: String, deviceHardware: DeviceHardware, firmwareFile: File) {
viewModelScope.launch {
_state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_starting_service))
serviceRepository.meshService?.setDeviceAddress(NO_DEVICE_SELECTED)
DfuServiceInitiator(address)
.disableResume()
.setDeviceName(deviceHardware.displayName)
.setForceScanningForNewAddressInLegacyDfu(true)
.setForeground(true)
.setKeepBond(true)
.setPacketsReceiptNotificationsEnabled(true)
.setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN)
.setScanTimeout(SCAN_TIMEOUT)
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
.setZip(Uri.fromFile(firmwareFile))
.start(context, FirmwareDfuService::class.java)
}
}
/**
* Bridges the callback-based DfuServiceListenerHelper to a Kotlin Flow. This decouples the listener implementation
* from the ViewModel state.
*/
private suspend fun observeDfuProgress() {
dfuProgressFlow(context)
.flowOn(Dispatchers.Main) // Listener Helper typically needs main thread for registration
.collect { dfuState ->
when (dfuState) {
is DfuInternalState.Progress -> {
val msg = getString(Res.string.firmware_update_updating, "${dfuState.percent}")
_state.value = FirmwareUpdateState.Updating(dfuState.percent / PERCENT_MAX_VALUE, msg)
}
is DfuInternalState.Error -> {
_state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}")
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Completed -> {
_state.value = FirmwareUpdateState.Success
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}")
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Aborted -> {
_state.value = FirmwareUpdateState.Error("DFU Aborted")
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Starting -> {
val msg = getString(Res.string.firmware_update_starting_dfu)
_state.value = FirmwareUpdateState.Processing(msg)
}
dfuProgressFlow(context).flowOn(Dispatchers.Main).collect { dfuState ->
when (dfuState) {
is DfuInternalState.Progress -> {
val msg = getString(Res.string.firmware_update_updating, "${dfuState.percent}")
_state.value = FirmwareUpdateState.Updating(dfuState.percent / PERCENT_MAX_VALUE, msg)
}
is DfuInternalState.Error -> {
_state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}")
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Completed -> {
_state.value = FirmwareUpdateState.Success
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}")
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Aborted -> {
_state.value = FirmwareUpdateState.Error("DFU Aborted")
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Starting -> {
val msg = getString(Res.string.firmware_update_starting_dfu)
_state.value = FirmwareUpdateState.Processing(msg)
}
}
}
private data class ValidationResult(
val node: org.meshtastic.core.database.model.Node,
val peripheral: no.nordicsemi.kotlin.ble.client.android.Peripheral,
val address: String,
)
/**
* Validates that a Meshtastic device is known (in Node DB), connected via Bluetooth, and has a valid Bluetooth
* address.
*/
private suspend fun validateDeviceAndConnection(): ValidationResult? {
val ourNode = nodeRepository.ourNodeInfo.value
val connectedPeripheral =
centralManager.getBondedPeripherals().firstOrNull { it.state.value == ConnectionState.Connected }
val address = connectedPeripheral?.address
return if (ourNode != null && connectedPeripheral != null && address != null) {
if (isValidBluetoothAddress(address)) {
ValidationResult(ourNode, connectedPeripheral, address)
} else {
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_invalid_address, address))
null
}
} else {
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_no_device))
null
}
}
private suspend fun getDeviceHardware(ourNode: org.meshtastic.core.database.model.Node): DeviceHardware? {
val hwModel = ourNode.user.hwModel?.number
return if (hwModel != null) {
val deviceHardware = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
if (deviceHardware != null) {
deviceHardware
} else {
deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrElse {
_state.value =
FirmwareUpdateState.Error(getString(Res.string.firmware_update_unknown_hardware, hwModel))
null
@ -411,21 +340,6 @@ constructor(
}
}
private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
// Architectures ordered by length descending to handle substrings like esp32-s3 vs esp32
val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
for (arch in knownArchs) {
if (url.contains(arch, ignoreCase = true)) {
// Replace the found architecture with the target architecture
// We use replacement to preserve the rest of the URL structure (version, server, etc.)
return url.replace(arch, targetArch.lowercase(), ignoreCase = true)
}
}
return url
}
private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmwareFile: File?): File? {
runCatching {
tempFirmwareFile?.takeIf { it.exists() }?.delete()
@ -435,7 +349,27 @@ private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmware
return null
}
/** Internal state representation for the DFU process flow. */
private fun waitForDeviceDetach(context: Context): Flow<Unit> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
trySend(Unit).isSuccess
close()
}
}
}
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
awaitClose { context.unregisterReceiver(receiver) }
}
private sealed interface DfuInternalState {
data class Starting(val address: String) : DfuInternalState
@ -456,7 +390,6 @@ private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType):
FirmwareReleaseType.ALPHA -> alphaRelease
}
/** Converts Nordic DFU callbacks to a cold Flow. Automatically registers/unregisters the listener. */
private fun dfuProgressFlow(context: Context): Flow<DfuInternalState> = callbackFlow {
val listener =
object : DfuProgressListenerAdapter() {
@ -492,123 +425,10 @@ private fun dfuProgressFlow(context: Context): Flow<DfuInternalState> = callback
awaitClose { DfuServiceListenerHelper.unregisterProgressListener(context, listener) }
}
/**
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
* extracting specific files from Zip archives.
*/
private class FirmwareFileHandler(private val context: Context, private val client: OkHttpClient) {
private val tempDir = File(context.cacheDir, "firmware_update")
sealed class FirmwareUpdateMethod(val description: StringResource) {
object Usb : FirmwareUpdateMethod(Res.string.firmware_update_method_usb)
fun cleanupAllTemporaryFiles() {
runCatching {
if (tempDir.exists()) {
tempDir.deleteRecursively()
}
tempDir.mkdirs()
}
.onFailure { e -> Timber.w(e, "Failed to cleanup temp directory") }
}
object Ble : FirmwareUpdateMethod(Res.string.firmware_update_method_ble)
suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).head().build()
try {
client.newCall(request).execute().use { response -> response.isSuccessful }
} catch (e: IOException) {
Timber.w(e, "Failed to check URL existence: $url")
false
}
}
suspend fun copyUriToFile(uri: Uri): File = withContext(Dispatchers.IO) {
val inputStream =
context.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open content URI")
// Ensure tempDir exists
if (!tempDir.exists()) tempDir.mkdirs()
val targetFile = File(tempDir, "local_update.zip")
inputStream.use { input -> FileOutputStream(targetFile).use { output -> input.copyTo(output) } }
targetFile
}
suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): File =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) throw IOException("Download failed: ${response.code}")
val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength()
// Ensure tempDir exists
if (!tempDir.exists()) tempDir.mkdirs()
val targetFile = File(tempDir, fileName)
body.byteStream().use { input ->
FileOutputStream(targetFile).use { output ->
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var bytesRead: Int
var totalBytesRead = 0L
while (input.read(buffer).also { bytesRead = it } != -1) {
// Check for coroutine cancellation during heavy IO loops
if (!isActive) throw CancellationException("Download cancelled")
output.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
onProgress(totalBytesRead.toFloat() / contentLength)
}
}
// Basic integrity check
if (contentLength != -1L && totalBytesRead != contentLength) {
throw IOException("Incomplete download: expected $contentLength bytes, got $totalBytesRead")
}
}
}
targetFile
}
suspend fun extractFirmware(zipFile: File, hardware: DeviceHardware): File? = withContext(Dispatchers.IO) {
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
if (target.isEmpty()) return@withContext null
val targetLowerCase = target.lowercase()
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
// Ensure tempDir exists
if (!tempDir.exists()) tempDir.mkdirs()
ZipInputStream(zipFile.inputStream()).use { zipInput ->
var entry = zipInput.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase)) {
val outFile = File(tempDir, File(name).name)
// We extract to verify it's a valid zip entry payload
FileOutputStream(outFile).use { output -> zipInput.copyTo(output) }
matchingEntries.add(entry to outFile)
}
entry = zipInput.nextEntry
}
}
// Best match heuristic: prefer shortest filename (e.g. 'tbeam' matches 'tbeam-s3', but 'tbeam' is shorter)
// This prevents flashing 'tbeam-s3' firmware onto a 'tbeam' device if both are present.
matchingEntries.minByOrNull { it.first.name.length }?.second
}
/**
* Checks if a filename matches the target device. Enforces stricter matching to avoid substring false positives
* (e.g. "tbeam" matching "tbeam-s3").
*/
private fun isValidFirmwareFile(filename: String, target: String): Boolean {
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*")
return filename.endsWith(".zip") &&
filename.contains(target) &&
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
}
object Unknown : FirmwareUpdateMethod(Res.string.unknown)
}

View file

@ -0,0 +1,221 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware
import android.content.Context
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import no.nordicsemi.android.dfu.DfuServiceInitiator
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import java.io.File
import javax.inject.Inject
private const val SCAN_TIMEOUT = 2000L
private const val PACKETS_BEFORE_PRN = 8
private const val REBOOT_DELAY = 5000L
private const val DATA_OBJECT_DELAY = 400L
/** Retrieves firmware files, either by direct download or by extracting from a release asset. */
class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFileHandler) {
suspend fun retrieveOtaFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): File? = retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
fileSuffix = "-ota.zip",
internalFileExtension = ".zip",
)
suspend fun retrieveUsbFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
): File? = retrieve(
release = release,
hardware = hardware,
onProgress = onProgress,
fileSuffix = ".uf2",
internalFileExtension = ".uf2",
)
private suspend fun retrieve(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
fileSuffix: String,
internalFileExtension: String,
): File? {
val version = release.id.removePrefix("v")
val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug }
val filename = "firmware-$target-$version$fileSuffix"
val directUrl =
"https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-$version/$filename"
if (fileHandler.checkUrlExists(directUrl)) {
try {
fileHandler.downloadFile(directUrl, filename, onProgress)?.let {
return it
}
} catch (e: Exception) {
Timber.w(e, "Direct download for $filename failed, falling back to release zip")
}
}
// Fallback to downloading the full release zip and extracting
val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture)
val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress)
return downloadedZip?.let { fileHandler.extractFirmware(it, hardware, internalFileExtension) }
}
private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32")
for (arch in knownArchs) {
if (url.contains(arch, ignoreCase = true)) {
return url.replace(arch, targetArch.lowercase(), ignoreCase = true)
}
}
return url
}
}
/** Handles the logic for Over-the-Air (OTA) firmware updates via Bluetooth. */
class OtaUpdateHandler
@Inject
constructor(
private val firmwareRetriever: FirmwareRetriever,
@ApplicationContext private val context: Context,
private val serviceRepository: ServiceRepository,
) {
suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
address: String,
updateState: (FirmwareUpdateState) -> Unit,
notFoundMsg: String,
startingMsg: String,
firmwareUri: Uri? = null,
): File? = try {
updateState(FirmwareUpdateState.Downloading(0f))
if (firmwareUri != null) {
initiateDfu(address, hardware, firmwareUri, updateState, startingMsg)
null
} else {
val firmwareFile =
firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress ->
updateState(FirmwareUpdateState.Downloading(progress))
}
if (firmwareFile == null) {
updateState(FirmwareUpdateState.Error(notFoundMsg))
null
} else {
initiateDfu(address, hardware, Uri.fromFile(firmwareFile), updateState, startingMsg)
firmwareFile
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e)
updateState(FirmwareUpdateState.Error(e.message ?: "OTA Update failed"))
null
}
private fun initiateDfu(
address: String,
deviceHardware: DeviceHardware,
firmwareUri: Uri,
updateState: (FirmwareUpdateState) -> Unit,
startingMsg: String,
) {
updateState(FirmwareUpdateState.Processing(startingMsg))
serviceRepository.meshService?.setDeviceAddress("n")
DfuServiceInitiator(address)
.disableResume()
.setDeviceName(deviceHardware.displayName)
.setForceScanningForNewAddressInLegacyDfu(true)
.setForeground(true)
.setKeepBond(true)
.setForceDfu(false)
.setPrepareDataObjectDelay(DATA_OBJECT_DELAY)
.setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN)
.setScanTimeout(SCAN_TIMEOUT)
.setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true)
.setZip(firmwareUri)
.start(context, FirmwareDfuService::class.java)
}
}
/** Handles the logic for firmware updates via USB. */
class UsbUpdateHandler
@Inject
constructor(
private val firmwareRetriever: FirmwareRetriever,
private val serviceRepository: ServiceRepository,
) {
suspend fun startUpdate(
release: FirmwareRelease,
hardware: DeviceHardware,
updateState: (FirmwareUpdateState) -> Unit,
rebootingMsg: String,
firmwareUri: Uri? = null,
): File? = try {
updateState(FirmwareUpdateState.Downloading(0f))
if (firmwareUri != null) {
updateState(FirmwareUpdateState.Processing(rebootingMsg))
serviceRepository.meshService?.rebootToDfu()
delay(REBOOT_DELAY)
updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri))
null
} else {
val firmwareFile =
firmwareRetriever.retrieveUsbFirmware(release, hardware) { progress ->
updateState(FirmwareUpdateState.Downloading(progress))
}
if (firmwareFile == null) {
updateState(FirmwareUpdateState.Error("Could not retrieve firmware file."))
null
} else {
updateState(FirmwareUpdateState.Processing(rebootingMsg))
serviceRepository.meshService?.rebootToDfu()
delay(REBOOT_DELAY)
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name))
firmwareFile
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e)
updateState(FirmwareUpdateState.Error(e.message ?: "USB Update failed"))
null
}
}

View file

@ -54,6 +54,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -134,7 +135,7 @@ fun SettingsScreen(
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val isDfuCapable by settingsViewModel.isDfuCapable.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsState()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {
@ -228,7 +229,7 @@ fun SettingsScreen(
if (state.isLocal) {
ourNode?.user?.longName
} else {
val remoteName = viewModel.destNode.value?.user?.longName ?: ""
val remoteName = destNode?.user?.longName ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = ourNode,
@ -244,7 +245,7 @@ fun SettingsScreen(
RadioConfigItemList(
state = state,
isManaged = localConfig.security.isManaged,
node = viewModel.destNode.value,
node = destNode,
excludedModulesUnlocked = excludedModulesUnlocked,
isDfuCapable = isDfuCapable,
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },

View file

@ -48,6 +48,8 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
@ -120,19 +122,12 @@ constructor(
.flatMapLatest { (node, connectionState) ->
if (node == null || !connectionState.isConnected()) {
flowOf(false)
} else if (radioPrefs.isBle() || radioPrefs.isSerial()) {
val hwModel = node.user.hwModel.number
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
flow { emit(hw?.requiresDfu == true) }
} else {
// Check BLE address
val address = radioPrefs.devAddr
if (address == null || !address.startsWith("x")) {
flowOf(false)
} else {
// Check hardware
val hwModel = node.user.hwModel.number
flow {
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
emit(hw?.requiresDfu == true)
}
}
flowOf(false)
}
}
.stateInWhileSubscribed(initialValue = false)