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
5 changed files with 181 additions and 37 deletions

View File

@@ -0,0 +1,67 @@
/*
* 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.core.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BootloaderWarningDataSource @Inject constructor(private val dataStore: DataStore<Preferences>) {
private object PreferencesKeys {
val DISMISSED_BOOTLOADER_ADDRESSES = stringPreferencesKey("dismissed-bootloader-addresses")
}
private val dismissedAddressesFlow =
dataStore.data.map { preferences ->
val jsonString = preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] ?: return@map emptySet()
runCatching { Json.decodeFromString<List<String>>(jsonString).toSet() }
.onFailure { e ->
if (e is IllegalArgumentException || e is SerializationException) {
Timber.w(e, "Failed to parse dismissed bootloader warning addresses, resetting preference")
} else {
Timber.w(e, "Unexpected error while parsing dismissed bootloader warning addresses")
}
}
.getOrDefault(emptySet())
}
/** Returns true if the bootloader warning has been dismissed for the given [address]. */
suspend fun isDismissed(address: String): Boolean = dismissedAddressesFlow.first().contains(address)
/** Marks the bootloader warning as dismissed for the given [address]. */
suspend fun dismiss(address: String) {
val current = dismissedAddressesFlow.first()
if (current.contains(address)) return
val updated = (current + address).toList()
dataStore.edit { preferences ->
preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] = Json.encodeToString(updated)
}
}
}

View File

@@ -964,6 +964,7 @@
<string name="firmware_update_usb_bootloader_warning">%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.</string>
<string name="learn_more">Learn more</string>
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor&apos;s serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
<string name="dont_show_again_for_device">Don&apos;t show again for this device</string>
<string name="preserve_favorites">Preserve Favorites?</string>
<string name="usb_devices">USB Devices</string>

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