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
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
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