feat: per device persistant dismissal of bootloader nags (#3859)

This commit is contained in:
Mac DeCourcy 2025-11-29 18:03:25 -08:00 committed by GitHub
parent ebab2ee9ad
commit 89e82ede59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 181 additions and 37 deletions

View file

@ -16,7 +16,7 @@
*/
@file:Suppress("TooManyFunctions")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
package org.meshtastic.feature.firmware
@ -102,6 +102,7 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
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
@ -145,17 +146,37 @@ fun FirmwareUpdateScreen(
uri?.let { viewModel.startUpdateFromFile(it) }
}
val shouldKeepScreenOn =
when (state) {
is FirmwareUpdateState.Downloading,
is FirmwareUpdateState.Processing,
is FirmwareUpdateState.Updating,
-> true
else -> false
}
val shouldKeepScreenOn = shouldKeepFirmwareScreenOn(state)
KeepScreenOn(shouldKeepScreenOn)
FirmwareUpdateScaffold(
modifier = modifier,
navController = navController,
state = state,
selectedReleaseType = selectedReleaseType,
onReleaseTypeSelect = viewModel::setReleaseType,
onStartUpdate = viewModel::startUpdate,
onPickFile = { launcher.launch("application/zip") },
onRetry = viewModel::checkForUpdates,
onDone = { navController.navigateUp() },
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
)
}
@Composable
private fun FirmwareUpdateScaffold(
navController: NavController,
state: FirmwareUpdateState,
selectedReleaseType: FirmwareReleaseType,
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
onStartUpdate: () -> Unit,
onPickFile: () -> Unit,
onRetry: () -> Unit,
onDone: () -> Unit,
onDismissBootloaderWarning: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
@ -189,17 +210,26 @@ fun FirmwareUpdateScreen(
FirmwareUpdateContent(
state = targetState,
selectedReleaseType = selectedReleaseType,
onReleaseTypeSelect = viewModel::setReleaseType,
onStartUpdate = viewModel::startUpdate,
onPickFile = { launcher.launch("application/zip") },
onRetry = viewModel::checkForUpdates,
onDone = { navController.navigateUp() },
onReleaseTypeSelect = onReleaseTypeSelect,
onStartUpdate = onStartUpdate,
onPickFile = onPickFile,
onRetry = onRetry,
onDone = onDone,
onDismissBootloaderWarning = onDismissBootloaderWarning,
)
}
}
}
}
private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = when (state) {
is FirmwareUpdateState.Downloading,
is FirmwareUpdateState.Processing,
is FirmwareUpdateState.Updating,
-> true
else -> false
}
@Composable
private fun FirmwareUpdateContent(
state: FirmwareUpdateState,
@ -209,6 +239,7 @@ private fun FirmwareUpdateContent(
onPickFile: () -> Unit,
onRetry: () -> Unit,
onDone: () -> Unit,
onDismissBootloaderWarning: () -> Unit,
) {
val modifier =
if (state is FirmwareUpdateState.Ready) {
@ -228,7 +259,14 @@ private fun FirmwareUpdateContent(
-> CheckingState()
is FirmwareUpdateState.Ready ->
ReadyState(state, selectedReleaseType, onReleaseTypeSelect, onStartUpdate, onPickFile)
ReadyState(
state = state,
selectedReleaseType = selectedReleaseType,
onReleaseTypeSelect = onReleaseTypeSelect,
onStartUpdate = onStartUpdate,
onPickFile = onPickFile,
onDismissBootloaderWarning = onDismissBootloaderWarning,
)
is FirmwareUpdateState.Downloading -> DownloadingState(state)
is FirmwareUpdateState.Processing -> ProcessingState(state.message)
@ -257,6 +295,7 @@ private fun ColumnScope.ReadyState(
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
onStartUpdate: () -> Unit,
onPickFile: () -> Unit,
onDismissBootloaderWarning: () -> Unit,
) {
var showDisclaimer by remember { mutableStateOf(false) }
var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
@ -277,9 +316,9 @@ private fun ColumnScope.ReadyState(
DeviceInfoCard(device, state.release)
if (device.requiresBootloaderUpgradeForOta == true) {
if (state.showBootloaderWarning) {
Spacer(Modifier.height(16.dp))
BootloaderWarningCard(device)
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = onDismissBootloaderWarning)
}
Spacer(Modifier.height(24.dp))
@ -452,7 +491,7 @@ private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRele
}
@Composable
private fun BootloaderWarningCard(deviceHardware: DeviceHardware) {
private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors =
@ -503,6 +542,11 @@ private fun BootloaderWarningCard(deviceHardware: DeviceHardware) {
Text(text = stringResource(Res.string.learn_more))
}
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onDismissForDevice) {
Text(text = stringResource(Res.string.dont_show_again_for_device))
}
}
}
}

View file

@ -25,8 +25,12 @@ sealed interface FirmwareUpdateState {
data object Checking : FirmwareUpdateState
data class Ready(val release: FirmwareRelease?, val deviceHardware: DeviceHardware, val address: String) :
FirmwareUpdateState
data class Ready(
val release: FirmwareRelease?,
val deviceHardware: DeviceHardware,
val address: String,
val showBootloaderWarning: Boolean,
) : FirmwareUpdateState
data class Downloading(val progress: Float) : FirmwareUpdateState

View file

@ -50,6 +50,7 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.NodeRepository
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.service.ServiceRepository
import org.meshtastic.core.strings.Res
@ -99,6 +100,7 @@ constructor(
client: OkHttpClient,
private val serviceRepository: ServiceRepository,
@ApplicationContext private val context: Context,
private val bootloaderWarningDataSource: BootloaderWarningDataSource,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@ -113,7 +115,7 @@ constructor(
init {
// Cleanup potential leftovers from previous crashes
fileHandler.cleanupAllTemporaryFiles()
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
checkForUpdates()
// Start listening to DFU events immediately
@ -122,7 +124,7 @@ constructor(
override fun onCleared() {
super.onCleared()
cleanupTemporaryFiles()
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
/** Sets the desired [FirmwareReleaseType] (e.g., ALPHA, STABLE) and triggers a new update check. */
@ -155,7 +157,15 @@ constructor(
val deviceHardware = getDeviceHardware(ourNode) ?: return@launch
firmwareReleaseRepository.getReleaseFlow(_selectedReleaseType.value).collectLatest { release ->
_state.value = FirmwareUpdateState.Ready(release, deviceHardware, address)
val dismissed = bootloaderWarningDataSource.isDismissed(address)
_state.value =
FirmwareUpdateState.Ready(
release = release,
deviceHardware = deviceHardware,
address = address,
showBootloaderWarning =
deviceHardware.requiresBootloaderUpgradeForOta == true && !dismissed,
)
}
}
.onFailure { e ->
@ -175,7 +185,9 @@ constructor(
@Suppress("TooGenericExceptionCaught")
fun startUpdate() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val (release, hardware, address) = currentState
val release = currentState.release
val hardware = currentState.deviceHardware
val address = currentState.address
if (release == null || !isValidBluetoothAddress(address)) return
@ -251,7 +263,8 @@ constructor(
@Suppress("TooGenericExceptionCaught")
fun startUpdateFromFile(uri: Uri) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val (_, hardware, address) = currentState
val hardware = currentState.deviceHardware
val address = currentState.address
if (!isValidBluetoothAddress(address)) return
@ -273,6 +286,21 @@ 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)
}
_state.value = currentState.copy(showBootloaderWarning = false)
}
}
/**
* Configures the DFU service and starts the update.
*
@ -316,16 +344,16 @@ constructor(
}
is DfuInternalState.Error -> {
_state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}")
cleanupTemporaryFiles()
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Completed -> {
_state.value = FirmwareUpdateState.Success
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}")
cleanupTemporaryFiles()
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Aborted -> {
_state.value = FirmwareUpdateState.Error("DFU Aborted")
cleanupTemporaryFiles()
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
is DfuInternalState.Starting -> {
val msg = getString(Res.string.firmware_update_starting_dfu)
@ -335,15 +363,6 @@ constructor(
}
}
private fun cleanupTemporaryFiles() {
runCatching {
tempFirmwareFile?.takeIf { it.exists() }?.delete()
fileHandler.cleanupAllTemporaryFiles()
}
.onFailure { e -> Timber.w(e, "Failed to cleanup temp files") }
tempFirmwareFile = null
}
private data class ValidationResult(
val node: org.meshtastic.core.database.model.Node,
val peripheral: no.nordicsemi.kotlin.ble.client.android.Peripheral,
@ -407,6 +426,15 @@ private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
return url
}
private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmwareFile: File?): File? {
runCatching {
tempFirmwareFile?.takeIf { it.exists() }?.delete()
fileHandler.cleanupAllTemporaryFiles()
}
.onFailure { e -> Timber.w(e, "Failed to cleanup temp files") }
return null
}
/** Internal state representation for the DFU process flow. */
private sealed interface DfuInternalState {
data class Starting(val address: String) : DfuInternalState