From 89e82ede593bf159c4ef59cc8f2956e0a3085e35 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Sat, 29 Nov 2025 18:03:25 -0800 Subject: [PATCH] feat: per device persistant dismissal of bootloader nags (#3859) --- .../datastore/BootloaderWarningDataSource.kt | 67 ++++++++++++++++ .../composeResources/values/strings.xml | 1 + .../feature/firmware/FirmwareUpdateScreen.kt | 80 ++++++++++++++----- .../feature/firmware/FirmwareUpdateState.kt | 8 +- .../firmware/FirmwareUpdateViewModel.kt | 62 ++++++++++---- 5 files changed, 181 insertions(+), 37 deletions(-) create mode 100644 core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt new file mode 100644 index 000000000..7770ea05b --- /dev/null +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt @@ -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 . + */ + +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) { + + 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>(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) + } + } +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 89c3e7f1b..c27c6225e 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -964,6 +964,7 @@ %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. Learn more For RAK WisBlock RAK4631, use the vendor'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. + Don't show again for this device Preserve Favorites? USB Devices diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 34b741446..d35e7244c 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -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)) + } } } } diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt index 26ddcd4b4..b0a0320ea 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt @@ -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 diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 58758ec1e..80d2c07ac 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -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.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