diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index 76e74ebff..d85d0acd6 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -112,6 +112,7 @@ import org.meshtastic.core.resources.firmware_update_release_notes import org.meshtastic.core.resources.firmware_update_retry import org.meshtastic.core.resources.firmware_update_save_dfu_file import org.meshtastic.core.resources.firmware_update_select_file +import org.meshtastic.core.resources.firmware_update_slow_bootloader_hint import org.meshtastic.core.resources.firmware_update_source_local import org.meshtastic.core.resources.firmware_update_stable import org.meshtastic.core.resources.firmware_update_success @@ -150,6 +151,14 @@ import org.meshtastic.core.ui.util.rememberSaveFileLauncher private const val CYCLE_DELAY_MS = 4500L +/** + * Flashing instructions for the OTAFIX 2.1 bootloader, which lifts the BLE DFU MTU cap (20-byte → 244-byte packets, + * ~10× faster updates). Offered on the Success screen after a low-speed transfer — never mid-upload, where leaving the + * app could drop the DFU link and brick the device. + */ +private const val OTAFIX_BOOTLOADER_URL = + "https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX#changes-in-otafix-21" + @Composable @Suppress("LongMethod") fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateViewModel, modifier: Modifier = Modifier) { @@ -328,7 +337,8 @@ private fun FirmwareUpdateContent( is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry) - is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone) + is FirmwareUpdateState.Success -> + SuccessState(onDone = actions.onDone, wasLowSpeedTransfer = state.wasLowSpeedTransfer) is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile) } @@ -866,8 +876,9 @@ internal fun ErrorState(error: UiText, onRetry: () -> Unit) { } @Composable -internal fun SuccessState(onDone: () -> Unit) { +internal fun SuccessState(wasLowSpeedTransfer: Boolean = false, onDone: () -> Unit) { val haptic = LocalHapticFeedback.current + val openUrl = rememberOpenUrl() LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -884,6 +895,18 @@ internal fun SuccessState(onDone: () -> Unit) { style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Center, ) + // The just-finished transfer was MTU-capped (stock bootloader). Now that the device is back and it's safe to + // leave the app, offer a one-time OTAFIX upgrade tip for faster future updates. + if (wasLowSpeedTransfer) { + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(Res.string.firmware_update_slow_bootloader_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + TextButton(onClick = { openUrl(OTAFIX_BOOTLOADER_URL) }) { Text(stringResource(Res.string.learn_more)) } + } Spacer(Modifier.height(32.dp)) @OptIn(ExperimentalMaterial3ExpressiveApi::class) val largeHeight = ButtonDefaults.LargeContainerHeight diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt index 1cb212b08..893dcaae2 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt @@ -72,8 +72,13 @@ sealed interface FirmwareUpdateState { /** An error occurred at any stage of the update pipeline. */ data class Error(val error: UiText) : FirmwareUpdateState - /** The firmware update completed and the device reconnected successfully. */ - data object Success : FirmwareUpdateState + /** + * The firmware update completed and the device reconnected successfully. + * + * @property wasLowSpeedTransfer True if the upload ran at the MTU-capped low speed (stock bootloader), so the + * Success screen can offer a one-time OTAFIX upgrade tip for faster future updates. + */ + data class Success(val wasLowSpeedTransfer: Boolean = false) : FirmwareUpdateState /** UF2 file is ready; waiting for the user to choose a save location (USB flow). */ data class AwaitingFileSave(val uf2Artifact: FirmwareArtifact, val fileName: String) : FirmwareUpdateState diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index c2555c9ad..7334a7af9 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -237,8 +237,9 @@ class FirmwareUpdateViewModel( updateState = { _state.value = it }, ) - when (_state.value) { - is FirmwareUpdateState.Success -> verifyUpdateResult(originalDeviceAddress) + when (val finalState = _state.value) { + is FirmwareUpdateState.Success -> + verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer) is FirmwareUpdateState.Error -> { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) @@ -325,8 +326,9 @@ class FirmwareUpdateViewModel( ) tempFirmwareFile = updateArtifact ?: extractedFile - when (_state.value) { - is FirmwareUpdateState.Success -> verifyUpdateResult(originalDeviceAddress) + when (val finalState = _state.value) { + is FirmwareUpdateState.Success -> + verifyUpdateResult(originalDeviceAddress, finalState.wasLowSpeedTransfer) is FirmwareUpdateState.Error -> { tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) @@ -355,7 +357,7 @@ class FirmwareUpdateViewModel( } } - private suspend fun verifyUpdateResult(address: String?) { + private suspend fun verifyUpdateResult(address: String?, wasLowSpeedTransfer: Boolean = false) { _state.value = FirmwareUpdateState.Verifying // Trigger a fresh connection attempt by MeshService using the original prefixed address @@ -378,7 +380,7 @@ class FirmwareUpdateViewModel( Logger.w { "Post-update verification timed out for $address" } _state.value = FirmwareUpdateState.VerificationFailed } else { - _state.value = FirmwareUpdateState.Success + _state.value = FirmwareUpdateState.Success(wasLowSpeedTransfer) } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 9126b8db1..bc48c3455 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -351,6 +351,6 @@ class Esp32OtaUpdateHandler( .getOrThrow() Logger.i { "ESP32 OTA: Firmware stream completed" } - updateState(FirmwareUpdateState.Success) + updateState(FirmwareUpdateState.Success()) } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index 5a9bd8e29..12592e57c 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -208,7 +208,7 @@ class SecureDfuHandler( ) completed = true - updateState(FirmwareUpdateState.Success) + updateState(FirmwareUpdateState.Success(wasLowSpeedTransfer = transport.isLowSpeedTransfer)) zipFile } finally { // Send ABORT if cancelled mid-transfer, then always clean up. diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 3de9ffb66..7f976ff48 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -129,7 +129,7 @@ class FirmwareUpdateIntegrationTest { @Suppress("UNCHECKED_CAST") val updateState = it.args[3] as (FirmwareUpdateState) -> Unit updateState(FirmwareUpdateState.Updating(ProgressState())) - updateState(FirmwareUpdateState.Success) + updateState(FirmwareUpdateState.Success()) null } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index a8eddff83..a19b8e455 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -187,7 +187,7 @@ class FirmwareUpdateViewModelTest { .calls { @Suppress("UNCHECKED_CAST") val updateState = it.args[3] as (FirmwareUpdateState) -> Unit - updateState(FirmwareUpdateState.Success) + updateState(FirmwareUpdateState.Success()) null } diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index 23a0d03ab..241cce4de 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -197,7 +197,7 @@ class FirmwareUpdateViewModelFileTest { .calls { @Suppress("UNCHECKED_CAST") val updateState = it.args[3] as (FirmwareUpdateState) -> Unit - updateState(FirmwareUpdateState.Success) + updateState(FirmwareUpdateState.Success()) null } @@ -257,7 +257,7 @@ class FirmwareUpdateViewModelFileTest { .calls { @Suppress("UNCHECKED_CAST") val updateState = it.args[3] as (FirmwareUpdateState) -> Unit - updateState(FirmwareUpdateState.Success) + updateState(FirmwareUpdateState.Success()) null }