Skip to content

Commit 89e82ed

Browse files
authored
feat: per device persistant dismissal of bootloader nags (#3859)
1 parent ebab2ee commit 89e82ed

File tree

5 files changed

+181
-37
lines changed

5 files changed

+181
-37
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) 2025 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
package org.meshtastic.core.datastore
19+
20+
import androidx.datastore.core.DataStore
21+
import androidx.datastore.preferences.core.Preferences
22+
import androidx.datastore.preferences.core.edit
23+
import androidx.datastore.preferences.core.stringPreferencesKey
24+
import kotlinx.coroutines.flow.first
25+
import kotlinx.coroutines.flow.map
26+
import kotlinx.serialization.SerializationException
27+
import kotlinx.serialization.json.Json
28+
import timber.log.Timber
29+
import javax.inject.Inject
30+
import javax.inject.Singleton
31+
32+
@Singleton
33+
class BootloaderWarningDataSource @Inject constructor(private val dataStore: DataStore<Preferences>) {
34+
35+
private object PreferencesKeys {
36+
val DISMISSED_BOOTLOADER_ADDRESSES = stringPreferencesKey("dismissed-bootloader-addresses")
37+
}
38+
39+
private val dismissedAddressesFlow =
40+
dataStore.data.map { preferences ->
41+
val jsonString = preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] ?: return@map emptySet()
42+
43+
runCatching { Json.decodeFromString<List<String>>(jsonString).toSet() }
44+
.onFailure { e ->
45+
if (e is IllegalArgumentException || e is SerializationException) {
46+
Timber.w(e, "Failed to parse dismissed bootloader warning addresses, resetting preference")
47+
} else {
48+
Timber.w(e, "Unexpected error while parsing dismissed bootloader warning addresses")
49+
}
50+
}
51+
.getOrDefault(emptySet())
52+
}
53+
54+
/** Returns true if the bootloader warning has been dismissed for the given [address]. */
55+
suspend fun isDismissed(address: String): Boolean = dismissedAddressesFlow.first().contains(address)
56+
57+
/** Marks the bootloader warning as dismissed for the given [address]. */
58+
suspend fun dismiss(address: String) {
59+
val current = dismissedAddressesFlow.first()
60+
if (current.contains(address)) return
61+
62+
val updated = (current + address).toList()
63+
dataStore.edit { preferences ->
64+
preferences[PreferencesKeys.DISMISSED_BOOTLOADER_ADDRESSES] = Json.encodeToString(updated)
65+
}
66+
}
67+
}

core/strings/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,7 @@
964964
<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>
965965
<string name="learn_more">Learn more</string>
966966
<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>
967+
<string name="dont_show_again_for_device">Don&apos;t show again for this device</string>
967968
<string name="preserve_favorites">Preserve Favorites?</string>
968969
<string name="usb_devices">USB Devices</string>
969970

feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
@file:Suppress("TooManyFunctions")
19-
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
19+
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
2020

2121
package org.meshtastic.feature.firmware
2222

@@ -102,6 +102,7 @@ import org.meshtastic.core.model.DeviceHardware
102102
import org.meshtastic.core.strings.Res
103103
import org.meshtastic.core.strings.cancel
104104
import org.meshtastic.core.strings.chirpy
105+
import org.meshtastic.core.strings.dont_show_again_for_device
105106
import org.meshtastic.core.strings.firmware_update_almost_there
106107
import org.meshtastic.core.strings.firmware_update_alpha
107108
import org.meshtastic.core.strings.firmware_update_button
@@ -145,17 +146,37 @@ fun FirmwareUpdateScreen(
145146
uri?.let { viewModel.startUpdateFromFile(it) }
146147
}
147148

148-
val shouldKeepScreenOn =
149-
when (state) {
150-
is FirmwareUpdateState.Downloading,
151-
is FirmwareUpdateState.Processing,
152-
is FirmwareUpdateState.Updating,
153-
-> true
154-
else -> false
155-
}
149+
val shouldKeepScreenOn = shouldKeepFirmwareScreenOn(state)
156150

157151
KeepScreenOn(shouldKeepScreenOn)
158152

153+
FirmwareUpdateScaffold(
154+
modifier = modifier,
155+
navController = navController,
156+
state = state,
157+
selectedReleaseType = selectedReleaseType,
158+
onReleaseTypeSelect = viewModel::setReleaseType,
159+
onStartUpdate = viewModel::startUpdate,
160+
onPickFile = { launcher.launch("application/zip") },
161+
onRetry = viewModel::checkForUpdates,
162+
onDone = { navController.navigateUp() },
163+
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
164+
)
165+
}
166+
167+
@Composable
168+
private fun FirmwareUpdateScaffold(
169+
navController: NavController,
170+
state: FirmwareUpdateState,
171+
selectedReleaseType: FirmwareReleaseType,
172+
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
173+
onStartUpdate: () -> Unit,
174+
onPickFile: () -> Unit,
175+
onRetry: () -> Unit,
176+
onDone: () -> Unit,
177+
onDismissBootloaderWarning: () -> Unit,
178+
modifier: Modifier = Modifier,
179+
) {
159180
Scaffold(
160181
modifier = modifier,
161182
topBar = {
@@ -189,17 +210,26 @@ fun FirmwareUpdateScreen(
189210
FirmwareUpdateContent(
190211
state = targetState,
191212
selectedReleaseType = selectedReleaseType,
192-
onReleaseTypeSelect = viewModel::setReleaseType,
193-
onStartUpdate = viewModel::startUpdate,
194-
onPickFile = { launcher.launch("application/zip") },
195-
onRetry = viewModel::checkForUpdates,
196-
onDone = { navController.navigateUp() },
213+
onReleaseTypeSelect = onReleaseTypeSelect,
214+
onStartUpdate = onStartUpdate,
215+
onPickFile = onPickFile,
216+
onRetry = onRetry,
217+
onDone = onDone,
218+
onDismissBootloaderWarning = onDismissBootloaderWarning,
197219
)
198220
}
199221
}
200222
}
201223
}
202224

225+
private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = when (state) {
226+
is FirmwareUpdateState.Downloading,
227+
is FirmwareUpdateState.Processing,
228+
is FirmwareUpdateState.Updating,
229+
-> true
230+
else -> false
231+
}
232+
203233
@Composable
204234
private fun FirmwareUpdateContent(
205235
state: FirmwareUpdateState,
@@ -209,6 +239,7 @@ private fun FirmwareUpdateContent(
209239
onPickFile: () -> Unit,
210240
onRetry: () -> Unit,
211241
onDone: () -> Unit,
242+
onDismissBootloaderWarning: () -> Unit,
212243
) {
213244
val modifier =
214245
if (state is FirmwareUpdateState.Ready) {
@@ -228,7 +259,14 @@ private fun FirmwareUpdateContent(
228259
-> CheckingState()
229260

230261
is FirmwareUpdateState.Ready ->
231-
ReadyState(state, selectedReleaseType, onReleaseTypeSelect, onStartUpdate, onPickFile)
262+
ReadyState(
263+
state = state,
264+
selectedReleaseType = selectedReleaseType,
265+
onReleaseTypeSelect = onReleaseTypeSelect,
266+
onStartUpdate = onStartUpdate,
267+
onPickFile = onPickFile,
268+
onDismissBootloaderWarning = onDismissBootloaderWarning,
269+
)
232270

233271
is FirmwareUpdateState.Downloading -> DownloadingState(state)
234272
is FirmwareUpdateState.Processing -> ProcessingState(state.message)
@@ -257,6 +295,7 @@ private fun ColumnScope.ReadyState(
257295
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
258296
onStartUpdate: () -> Unit,
259297
onPickFile: () -> Unit,
298+
onDismissBootloaderWarning: () -> Unit,
260299
) {
261300
var showDisclaimer by remember { mutableStateOf(false) }
262301
var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
@@ -277,9 +316,9 @@ private fun ColumnScope.ReadyState(
277316

278317
DeviceInfoCard(device, state.release)
279318

280-
if (device.requiresBootloaderUpgradeForOta == true) {
319+
if (state.showBootloaderWarning) {
281320
Spacer(Modifier.height(16.dp))
282-
BootloaderWarningCard(device)
321+
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = onDismissBootloaderWarning)
283322
}
284323

285324
Spacer(Modifier.height(24.dp))
@@ -452,7 +491,7 @@ private fun DeviceInfoCard(deviceHardware: DeviceHardware, release: FirmwareRele
452491
}
453492

454493
@Composable
455-
private fun BootloaderWarningCard(deviceHardware: DeviceHardware) {
494+
private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) {
456495
ElevatedCard(
457496
modifier = Modifier.fillMaxWidth(),
458497
colors =
@@ -503,6 +542,11 @@ private fun BootloaderWarningCard(deviceHardware: DeviceHardware) {
503542
Text(text = stringResource(Res.string.learn_more))
504543
}
505544
}
545+
546+
Spacer(Modifier.height(8.dp))
547+
TextButton(onClick = onDismissForDevice) {
548+
Text(text = stringResource(Res.string.dont_show_again_for_device))
549+
}
506550
}
507551
}
508552
}

feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ sealed interface FirmwareUpdateState {
2525

2626
data object Checking : FirmwareUpdateState
2727

28-
data class Ready(val release: FirmwareRelease?, val deviceHardware: DeviceHardware, val address: String) :
29-
FirmwareUpdateState
28+
data class Ready(
29+
val release: FirmwareRelease?,
30+
val deviceHardware: DeviceHardware,
31+
val address: String,
32+
val showBootloaderWarning: Boolean,
33+
) : FirmwareUpdateState
3034

3135
data class Downloading(val progress: Float) : FirmwareUpdateState
3236

feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
5050
import org.meshtastic.core.data.repository.NodeRepository
5151
import org.meshtastic.core.database.entity.FirmwareRelease
5252
import org.meshtastic.core.database.entity.FirmwareReleaseType
53+
import org.meshtastic.core.datastore.BootloaderWarningDataSource
5354
import org.meshtastic.core.model.DeviceHardware
5455
import org.meshtastic.core.service.ServiceRepository
5556
import org.meshtastic.core.strings.Res
@@ -99,6 +100,7 @@ constructor(
99100
client: OkHttpClient,
100101
private val serviceRepository: ServiceRepository,
101102
@ApplicationContext private val context: Context,
103+
private val bootloaderWarningDataSource: BootloaderWarningDataSource,
102104
) : ViewModel() {
103105

104106
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -113,7 +115,7 @@ constructor(
113115

114116
init {
115117
// Cleanup potential leftovers from previous crashes
116-
fileHandler.cleanupAllTemporaryFiles()
118+
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
117119
checkForUpdates()
118120

119121
// Start listening to DFU events immediately
@@ -122,7 +124,7 @@ constructor(
122124

123125
override fun onCleared() {
124126
super.onCleared()
125-
cleanupTemporaryFiles()
127+
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
126128
}
127129

128130
/** Sets the desired [FirmwareReleaseType] (e.g., ALPHA, STABLE) and triggers a new update check. */
@@ -155,7 +157,15 @@ constructor(
155157
val deviceHardware = getDeviceHardware(ourNode) ?: return@launch
156158

157159
firmwareReleaseRepository.getReleaseFlow(_selectedReleaseType.value).collectLatest { release ->
158-
_state.value = FirmwareUpdateState.Ready(release, deviceHardware, address)
160+
val dismissed = bootloaderWarningDataSource.isDismissed(address)
161+
_state.value =
162+
FirmwareUpdateState.Ready(
163+
release = release,
164+
deviceHardware = deviceHardware,
165+
address = address,
166+
showBootloaderWarning =
167+
deviceHardware.requiresBootloaderUpgradeForOta == true && !dismissed,
168+
)
159169
}
160170
}
161171
.onFailure { e ->
@@ -175,7 +185,9 @@ constructor(
175185
@Suppress("TooGenericExceptionCaught")
176186
fun startUpdate() {
177187
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
178-
val (release, hardware, address) = currentState
188+
val release = currentState.release
189+
val hardware = currentState.deviceHardware
190+
val address = currentState.address
179191

180192
if (release == null || !isValidBluetoothAddress(address)) return
181193

@@ -251,7 +263,8 @@ constructor(
251263
@Suppress("TooGenericExceptionCaught")
252264
fun startUpdateFromFile(uri: Uri) {
253265
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
254-
val (_, hardware, address) = currentState
266+
val hardware = currentState.deviceHardware
267+
val address = currentState.address
255268

256269
if (!isValidBluetoothAddress(address)) return
257270

@@ -273,6 +286,21 @@ constructor(
273286
}
274287
}
275288

289+
/** Persists dismissal of the bootloader warning for the current device and updates state accordingly. */
290+
fun dismissBootloaderWarningForCurrentDevice() {
291+
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
292+
val address = currentState.address
293+
294+
viewModelScope.launch {
295+
runCatching { bootloaderWarningDataSource.dismiss(address) }
296+
.onFailure { e ->
297+
Timber.w(e, "Failed to persist bootloader warning dismissal for address=%s", address)
298+
}
299+
300+
_state.value = currentState.copy(showBootloaderWarning = false)
301+
}
302+
}
303+
276304
/**
277305
* Configures the DFU service and starts the update.
278306
*
@@ -316,16 +344,16 @@ constructor(
316344
}
317345
is DfuInternalState.Error -> {
318346
_state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}")
319-
cleanupTemporaryFiles()
347+
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
320348
}
321349
is DfuInternalState.Completed -> {
322350
_state.value = FirmwareUpdateState.Success
323351
serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}")
324-
cleanupTemporaryFiles()
352+
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
325353
}
326354
is DfuInternalState.Aborted -> {
327355
_state.value = FirmwareUpdateState.Error("DFU Aborted")
328-
cleanupTemporaryFiles()
356+
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
329357
}
330358
is DfuInternalState.Starting -> {
331359
val msg = getString(Res.string.firmware_update_starting_dfu)
@@ -335,15 +363,6 @@ constructor(
335363
}
336364
}
337365

338-
private fun cleanupTemporaryFiles() {
339-
runCatching {
340-
tempFirmwareFile?.takeIf { it.exists() }?.delete()
341-
fileHandler.cleanupAllTemporaryFiles()
342-
}
343-
.onFailure { e -> Timber.w(e, "Failed to cleanup temp files") }
344-
tempFirmwareFile = null
345-
}
346-
347366
private data class ValidationResult(
348367
val node: org.meshtastic.core.database.model.Node,
349368
val peripheral: no.nordicsemi.kotlin.ble.client.android.Peripheral,
@@ -407,6 +426,15 @@ private fun getDeviceFirmwareUrl(url: String, targetArch: String): String {
407426
return url
408427
}
409428

429+
private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmwareFile: File?): File? {
430+
runCatching {
431+
tempFirmwareFile?.takeIf { it.exists() }?.delete()
432+
fileHandler.cleanupAllTemporaryFiles()
433+
}
434+
.onFailure { e -> Timber.w(e, "Failed to cleanup temp files") }
435+
return null
436+
}
437+
410438
/** Internal state representation for the DFU process flow. */
411439
private sealed interface DfuInternalState {
412440
data class Starting(val address: String) : DfuInternalState

0 commit comments

Comments
 (0)