mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-29 02:08:32 -04:00
feat: per device persistant dismissal of bootloader nags (#3859)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'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't show again for this device</string>
|
||||
<string name="preserve_favorites">Preserve Favorites?</string>
|
||||
<string name="usb_devices">USB Devices</string>
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user