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