mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
f322eb31a0
commit
499ed58311
13 changed files with 860 additions and 483 deletions
10
feature/firmware/detekt-baseline.xml
Normal file
10
feature/firmware/detekt-baseline.xml
Normal 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>
|
||||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue