fix(dfu): Detect legacy bootloaders before secure fallback (#6079)

This commit is contained in:
Jeremiah K
2026-07-08 19:18:31 -05:00
committed by GitHub
parent 9e55cdb3eb
commit 3e86c377b2
2 changed files with 434 additions and 49 deletions

View File

@@ -56,6 +56,7 @@ import org.meshtastic.feature.firmware.ota.retryWithDelay
import org.meshtastic.feature.firmware.ota.scanForBleDevice
import org.meshtastic.feature.firmware.stripFormatArgs
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
private const val PERCENT_MAX = 100
private const val GATT_RELEASE_DELAY_MS = 1_500L
@@ -63,6 +64,145 @@ private const val DFU_REBOOT_WAIT_MS = 3_000L
private const val RETRY_DELAY_MS = 2_000L
private const val CONNECT_ATTEMPTS = 4
/** Legacy DFU can't resume, so retry the whole session this many times before giving up. */
private const val LEGACY_SESSION_ATTEMPTS = 3
/** Limited probe/fallback budget for inconclusive detection or alternate-protocol attempts. */
private const val LIMITED_SESSION_ATTEMPTS = 1
/**
* Transport-dispatch key: which [DfuUploadTransport] implementation ([SecureDfuTransport] or [LegacyDfuTransport]) to
* use, and therefore which DFU service UUID its scan/connect is filtering on.
*/
internal enum class DfuProtocolKind {
SECURE,
LEGACY,
}
/**
* Outcome of bootloader service detection. Unlike [DfuProtocolKind] (which is the transport-dispatch key), this type
* keeps the "neither service observed" case representable instead of silently coercing it to one protocol.
*/
internal sealed class BootloaderDetection {
/** Legacy DFU service (`1530`) was observed in the advertisement. Treat the bootloader as Legacy DFU. */
data object LegacyObserved : BootloaderDetection()
/** Secure DFU service (`FE59`) was observed in the advertisement. Treat the bootloader as Secure DFU. */
data object SecureObserved : BootloaderDetection()
/**
* Neither DFU service was conclusively observed inside the detection window. Causes of an inconclusive scan include
* advertisement-interval timing, Android scan duty-cycling, OS-level advertisement cache, and the bootloader not
* having resumed advertising yet after the buttonless reboot.
*/
data object Unknown : BootloaderDetection()
}
internal sealed class DfuUploadResult {
data object Success : DfuUploadResult()
data class Failure(val error: Throwable, val protocolEngaged: Boolean) : DfuUploadResult()
}
/**
* Owns fallback ordering and retry budget policy. Fallback is intentionally limited to failures that happen before the
* selected transport has connected and engaged its DFU protocol session.
*/
internal class DfuFallbackCoordinator(private val detection: BootloaderDetection) {
suspend fun execute(uploadWithRetry: suspend (DfuProtocolKind, Int) -> DfuUploadResult) {
val orderedProtocols = detection.orderedProtocols()
Logger.i { "DFU: detection=$detection → protocols=$orderedProtocols" }
var primaryError: Throwable? = null
for ((index, protocol) in orderedProtocols.withIndex()) {
val isPrimary = index == 0
val hasAlternateProtocol = index < orderedProtocols.lastIndex
val sessionAttempts = sessionAttemptsFor(protocol, isPrimary)
if (isPrimary) {
Logger.i { "DFU: primary protocol=$protocol ($sessionAttempts session attempt(s))" }
} else {
Logger.w {
"DFU: falling back to alternate protocol=$protocol before protocol engagement " +
"(detection=$detection, sessionAttempts=$sessionAttempts)"
}
}
when (val result = uploadWithRetry(protocol, sessionAttempts)) {
DfuUploadResult.Success -> return
is DfuUploadResult.Failure -> {
if (isPrimary) {
primaryError = result.error
}
if (!hasAlternateProtocol || !shouldTryAlternateAfterFailure(isPrimary, result.protocolEngaged)) {
throw primaryError.withSuppressedAlternate(result.error)
}
Logger.w {
"DFU: $protocol failed; trying alternate protocol (detection=$detection, " +
"protocolEngaged=${result.protocolEngaged}): ${result.error::class.simpleName}"
}
}
}
}
throw IllegalStateException("DFU fallback exhausted with non-empty protocol list (detection=$detection)")
}
/**
* The ordered list of protocols to attempt for this detection outcome. The first element is the primary; the second
* is the alternate, attempted only if the primary fails before protocol engagement.
*/
private fun BootloaderDetection.orderedProtocols(): List<DfuProtocolKind> = when (this) {
BootloaderDetection.LegacyObserved -> listOf(DfuProtocolKind.LEGACY, DfuProtocolKind.SECURE)
BootloaderDetection.SecureObserved -> listOf(DfuProtocolKind.SECURE, DfuProtocolKind.LEGACY)
BootloaderDetection.Unknown -> listOf(DfuProtocolKind.LEGACY, DfuProtocolKind.SECURE)
}
/**
* Session retry budget for the given protocol in this detection context.
* - Confirmed Legacy primary ([BootloaderDetection.LegacyObserved]): [LEGACY_SESSION_ATTEMPTS] (3) to cover the
* stale-session reset-prime recovery path.
* - Unknown Legacy primary and alternate protocols: [LIMITED_SESSION_ATTEMPTS] (1) to keep speculative probes short
* enough that a Secure bootloader still has time to accept its fallback attempt.
*/
private fun sessionAttemptsFor(protocol: DfuProtocolKind, isPrimary: Boolean): Int = when {
protocol == DfuProtocolKind.LEGACY && isPrimary && detection == BootloaderDetection.LegacyObserved ->
LEGACY_SESSION_ATTEMPTS
else -> LIMITED_SESSION_ATTEMPTS
}
private fun shouldTryAlternateAfterFailure(isPrimary: Boolean, protocolEngaged: Boolean): Boolean {
if (!isPrimary) return false
return when (detection) {
// Unknown can mean the primary Legacy advertisement was missed, so a pre-engagement failure is the useful
// fallback signal.
BootloaderDetection.Unknown -> !protocolEngaged
// Conclusive detections already observed the selected protocol's service. If it never engages, retrying the
// opposite service is usually a long doomed scan. Keep fallback only as insurance for a stale/wrong
// conclusive signal after the observed service was actually reached.
BootloaderDetection.LegacyObserved,
BootloaderDetection.SecureObserved,
-> protocolEngaged
}
}
private fun Throwable?.withSuppressedAlternate(alternateError: Throwable): Throwable {
val primary = this ?: return alternateError
if (primary !== alternateError) {
primary.addSuppressed(alternateError)
}
return primary
}
}
/** The DFU service UUID this protocol scans for, for diagnostic logging. Mirrors the transport's own scan filter. */
private fun DfuProtocolKind.serviceUuid(): Uuid = when (this) {
DfuProtocolKind.LEGACY -> LegacyDfuUuids.SERVICE
DfuProtocolKind.SECURE -> SecureDfuUuids.SERVICE
}
/**
* KMP [FirmwareUpdateHandler] for nRF52 devices.
*
@@ -127,9 +267,9 @@ class SecureDfuHandler(
}
delay(DFU_REBOOT_WAIT_MS)
// ── 4. Service detection: which DFU protocol does the bootloader speak? ─
val protocol = detectBootloaderProtocol(target, updateState)
Logger.i { "DFU: Bootloader protocol detected: $protocol" }
// ── 4. Service detection: which DFU service does the bootloader advertise? ─
val detection = detectBootloaderProtocol(target, updateState)
Logger.i { "DFU: Bootloader detection = $detection" }
// NOTE: do NOT drop the bond for a same-address Legacy bootloader. When Meshtastic triggers
// buttonless DFU it hands the app's bond keys to the bootloader (peer_data), and the Adafruit
@@ -138,13 +278,18 @@ class SecureDfuHandler(
// identity so it no longer matches the whitelist — the phone then can't connect at all. The
// shared LTK also lets the DFU link encrypt cleanly (verified on-air: AES-128, keySize 16), so the
// bond must be KEPT, mirroring Nordic's DfuServiceInitiator.setKeepBond(true)/setRestoreBond(true).
// This applies to BOTH protocols and to the fallback path — the bond is never removed or refreshed
// anywhere in this handler.
// Legacy DFU has no resume, so a failed session is retried whole (fresh transport + reconnect +
// re-handshake), mirroring Nordic's DFU library ("the Legacy DFU will start again"). A stock
// bootloader that leaves the first session's control-point handshake unanswered often responds
// Legacy DFU has no resume, so a confirmed Legacy session is retried whole (fresh transport +
// reconnect + re-handshake), mirroring Nordic's DFU library ("the Legacy DFU will start again"). A
// stock bootloader that leaves the first session's control-point handshake unanswered often responds
// after a clean reconnect. Secure DFU resumes in place, so it runs a single session.
val sessionAttempts = if (protocol == DfuProtocolKind.LEGACY) LEGACY_SESSION_ATTEMPTS else 1
runDfuUploadWithRetry(protocol, target, pkg, sessionAttempts, updateState)
// DfuFallbackCoordinator resolves the detection into an ordered protocol list and may try the
// alternate protocol only before the selected protocol has connected and engaged its DFU session.
DfuFallbackCoordinator(detection).execute { protocol, sessionAttempts ->
runDfuUploadWithRetry(protocol, target, pkg, sessionAttempts, updateState)
}
zipFile
}
} catch (e: CancellationException) {
@@ -167,35 +312,69 @@ class SecureDfuHandler(
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Detect which DFU protocol the bootloader speaks by scanning for advertised service UUIDs. We scan for the legacy
* service (1530) first with a short timeout — Adafruit/oltaco bootloaders always advertise it, while Nordic Secure
* bootloaders never do, so a hit unambiguously means Legacy. Miss ⇒ assume Secure (preserves current behavior on
* unaffected devices).
* Detect which DFU service the bootloader advertises by scanning first for the Legacy (`1530`) service UUID. If
* Legacy is observed, return immediately; otherwise scan for the Secure (`FE59`) service UUID. The scans run
* sequentially because BLE scan is a shared OS resource and parallel scans are unreliable across Android versions.
*
* Returns a [BootloaderDetection] that keeps the inconclusive case (`Unknown`) *representable* instead of silently
* coercing a missed Legacy scan into a Secure-DFU verdict. The fallback coordinator resolves a detection into an
* ordered list of protocols to attempt, so a `Unknown` result no longer locks the handler into a single transport —
* both protocols are tried (Legacy first) before the update is declared failed. `Unknown` means neither service was
* observed in the detection windows.
*/
private suspend fun detectBootloaderProtocol(
target: String,
updateState: (FirmwareUpdateState) -> Unit,
): DfuProtocolKind {
): BootloaderDetection {
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
)
val targetAddresses = setOf(target, calculateMacPlusOne(target))
val macPlusOne = calculateMacPlusOne(target)
val targetAddresses = setOf(target, macPlusOne)
Logger.i { "DFU: detect — predicate matches original MAC and MAC+1" }
Logger.i { "DFU: detect — scanning for Legacy DFU service (${LegacyDfuUuids.SERVICE})" }
val legacyHit =
scanForBleDevice(
scanner = bleScanner,
tag = "DFU detect",
tag = "DFU detect (legacy)",
serviceUuid = LegacyDfuUuids.SERVICE,
retryCount = 1,
retryDelay = 0.seconds,
scanTimeout = DETECT_SCAN_TIMEOUT,
predicate = { it.address in targetAddresses },
)
return if (legacyHit != null) DfuProtocolKind.LEGACY else DfuProtocolKind.SECURE
if (legacyHit != null) {
Logger.i {
"DFU: detect — Legacy service observed; skipping Secure DFU scan (Legacy wins per orderedProtocols)"
}
return BootloaderDetection.LegacyObserved
}
Logger.i { "DFU: detect — scanning for Secure DFU service (${SecureDfuUuids.SERVICE})" }
val secureHit =
scanForBleDevice(
scanner = bleScanner,
tag = "DFU detect (secure)",
serviceUuid = SecureDfuUuids.SERVICE,
retryCount = 1,
retryDelay = 0.seconds,
scanTimeout = DETECT_SCAN_TIMEOUT,
predicate = { it.address in targetAddresses },
)
val detection = if (secureHit != null) BootloaderDetection.SecureObserved else BootloaderDetection.Unknown
Logger.i { "DFU: detect — secureHit=${secureHit != null}, result=$detection" }
return detection
}
/**
* Run the connect + init + firmware upload, retrying the whole session up to [attempts] times. Each attempt uses a
* fresh [DfuUploadTransport] (new GATT connection + re-handshake) since Legacy DFU can't resume mid-stream.
*
* Returns the last failure with whether this protocol ever connected and started its DFU session, allowing fallback
* orchestration to switch protocols only for pre-engagement failures.
*/
private suspend fun runDfuUploadWithRetry(
protocol: DfuProtocolKind,
@@ -203,30 +382,36 @@ class SecureDfuHandler(
pkg: DfuZipPackage,
attempts: Int,
updateState: (FirmwareUpdateState) -> Unit,
) {
): DfuUploadResult {
var lastError: Throwable? = null
var protocolEngaged = false
repeat(attempts) { i ->
val attempt = i + 1
Logger.i { "DFU: upload session attempt $attempt/$attempts" }
try {
runUploadSession(protocol, target, pkg, updateState)
return
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
lastError = e
Logger.w(e) { "DFU: upload session $attempt/$attempts failed: ${e.message}" }
// A stock bootloader holding a wedged session from an interrupted flash rejects START with
// INVALID_STATE, then goes unresponsive (the RESET can't land on that connection). A *fresh*
// connection is responsive up until START, so reset it there — the device reboots into a clean OTA
// session (GPREGRET OTA flag is retained) that the next attempt can flash normally.
if (e is LegacyDfuException.StaleSessionReset && attempt < attempts) {
resetStaleBootloader(protocol, target)
Logger.i { "DFU: upload session attempt $attempt/$attempts ($protocol)" }
when (val result = runUploadSession(protocol, target, pkg, updateState)) {
DfuUploadResult.Success -> return result
is DfuUploadResult.Failure -> {
lastError = result.error
protocolEngaged = protocolEngaged || result.protocolEngaged
Logger.w(result.error) {
"DFU: upload session $attempt/$attempts failed ($protocol): ${result.error::class.simpleName}"
}
// A stock bootloader holding a wedged session from an interrupted flash rejects START with
// INVALID_STATE, then goes unresponsive (the RESET can't land on that connection). A *fresh*
// connection is responsive up until START, so reset it there — the device reboots into a clean OTA
// session (GPREGRET OTA flag is retained) that the next attempt can flash normally.
if (result.error is LegacyDfuException.StaleSessionReset && attempt < attempts) {
resetStaleBootloader(protocol, target)
}
if (attempt < attempts) delay(SESSION_RETRY_DELAY_MS)
}
if (attempt < attempts) delay(SESSION_RETRY_DELAY_MS)
}
}
throw lastError ?: DfuException.TransferFailed("DFU upload failed after $attempts attempts")
return DfuUploadResult.Failure(
lastError ?: DfuException.TransferFailed("DFU upload failed after $attempts attempts ($protocol)"),
protocolEngaged,
)
}
private fun createTransport(protocol: DfuProtocolKind, target: String): DfuUploadTransport = when (protocol) {
@@ -262,11 +447,13 @@ class SecureDfuHandler(
target: String,
pkg: DfuZipPackage,
updateState: (FirmwareUpdateState) -> Unit,
) {
): DfuUploadResult {
val transport: DfuUploadTransport = createTransport(protocol, target)
var completed = false
var protocolEngaged = false
try {
connectWithRetry(transport, updateState)
connectWithRetry(transport, protocol, updateState)
protocolEngaged = true
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_starting_dfu))),
@@ -305,6 +492,11 @@ class SecureDfuHandler(
completed = true
updateState(FirmwareUpdateState.Success(wasLowSpeedTransfer = transport.isLowSpeedTransfer))
return DfuUploadResult.Success
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
return DfuUploadResult.Failure(e, protocolEngaged)
} finally {
withContext(NonCancellable) {
if (!completed) transport.abort()
@@ -313,7 +505,12 @@ class SecureDfuHandler(
}
}
private suspend fun connectWithRetry(transport: DfuUploadTransport, updateState: (FirmwareUpdateState) -> Unit) {
private suspend fun connectWithRetry(
transport: DfuUploadTransport,
protocol: DfuProtocolKind,
updateState: (FirmwareUpdateState) -> Unit,
) {
val serviceUuid = protocol.serviceUuid()
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
)
@@ -334,14 +531,19 @@ class SecureDfuHandler(
)
},
block = { attempt ->
Logger.i {
"DFU: Connect attempt $attempt/$CONNECT_ATTEMPTS via $protocol (scan service=$serviceUuid)"
}
transport.connectToDfuMode().onFailure {
Logger.w { "DFU: Connect attempt $attempt/$CONNECT_ATTEMPTS failed: ${it.message}" }
Logger.w(it) {
"DFU: Connect attempt $attempt/$CONNECT_ATTEMPTS via $protocol failed: ${it.message}"
}
}
},
)
.getOrElse {
throw DfuException.ConnectionFailed(
"Failed to connect to DFU device after $CONNECT_ATTEMPTS attempts",
"Failed to connect to DFU device via $protocol after $CONNECT_ATTEMPTS attempts",
it,
)
}
@@ -385,19 +587,13 @@ class SecureDfuHandler(
return path
}
/** Result of [detectBootloaderProtocol]. */
internal enum class DfuProtocolKind {
SECURE,
LEGACY,
}
private companion object {
/** Detection scan timeout — short because we only want to confirm/refute an advertised legacy service. */
/**
* Per-service scan timeout for bootloader protocol detection. Applied to both Legacy (1530) and Secure (FE59)
* scans. Sequential, so worst-case detection is 2× this value.
*/
private val DETECT_SCAN_TIMEOUT = 8.seconds
/** Legacy DFU can't resume, so retry the whole session this many times before giving up. */
private const val LEGACY_SESSION_ATTEMPTS = 3
/** Delay between whole-session retries (lets the bootloader settle / resume advertising). */
private const val SESSION_RETRY_DELAY_MS = 2_000L

View File

@@ -0,0 +1,189 @@
/*
* Copyright (c) 2026 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/>.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package org.meshtastic.feature.firmware.ota.dfu
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class DfuFallbackCoordinatorTest {
@Test
fun `LegacyObserved tries Legacy first`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.LegacyObserved)
val protocols = mutableListOf<DfuProtocolKind>()
coordinator.execute { protocol, _ ->
protocols.add(protocol)
DfuUploadResult.Success
}
assertEquals(listOf(DfuProtocolKind.LEGACY), protocols)
}
@Test
fun `SecureObserved tries Secure first`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.SecureObserved)
val protocols = mutableListOf<DfuProtocolKind>()
coordinator.execute { protocol, _ ->
protocols.add(protocol)
DfuUploadResult.Success
}
assertEquals(listOf(DfuProtocolKind.SECURE), protocols)
}
@Test
fun `Unknown tries Legacy first`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.Unknown)
val protocols = mutableListOf<DfuProtocolKind>()
coordinator.execute { protocol, _ ->
protocols.add(protocol)
DfuUploadResult.Success
}
assertEquals(listOf(DfuProtocolKind.LEGACY), protocols) // Success on first = no fallback
}
@Test
fun `Unknown falls back from Legacy to Secure on pre-engagement failure`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.Unknown)
val protocols = mutableListOf<DfuProtocolKind>()
val attemptCounts = mutableListOf<Int>()
assertFailsWith<RuntimeException> {
coordinator.execute { protocol, attempts ->
protocols.add(protocol)
attemptCounts.add(attempts)
if (protocol == DfuProtocolKind.LEGACY) {
DfuUploadResult.Failure(RuntimeException("connect failed"), protocolEngaged = false)
} else {
DfuUploadResult.Failure(RuntimeException("also failed"), protocolEngaged = false)
}
}
}
.also { thrown ->
assertEquals("connect failed", thrown.message)
assertEquals(1, thrown.suppressedExceptions.size, "alternate error should be suppressed on primary")
assertEquals("also failed", thrown.suppressedExceptions.first().message)
}
assertEquals(listOf(DfuProtocolKind.LEGACY, DfuProtocolKind.SECURE), protocols)
assertEquals(listOf(1, 1), attemptCounts)
}
@Test
fun `protocolEngaged prevents fallback`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.Unknown)
val protocols = mutableListOf<DfuProtocolKind>()
assertFailsWith<RuntimeException> {
coordinator.execute { protocol, _ ->
protocols.add(protocol)
DfuUploadResult.Failure(RuntimeException("mid-transfer"), protocolEngaged = true)
}
}
assertEquals(listOf(DfuProtocolKind.LEGACY), protocols) // No fallback despite Unknown
}
@Test
fun `LegacyObserved gives Legacy 3 session attempts`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.LegacyObserved)
val attemptCounts = mutableListOf<Int>()
coordinator.execute { _, attempts ->
attemptCounts.add(attempts)
DfuUploadResult.Success
}
assertEquals(listOf(3), attemptCounts) // LEGACY_SESSION_ATTEMPTS
}
@Test
fun `LegacyObserved skips Secure fallback when Legacy never engages`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.LegacyObserved)
val protocols = mutableListOf<DfuProtocolKind>()
assertFailsWith<RuntimeException> {
coordinator.execute { protocol, _ ->
protocols.add(protocol)
DfuUploadResult.Failure(RuntimeException("legacy connect failed"), protocolEngaged = false)
}
}
.also { thrown -> assertEquals("legacy connect failed", thrown.message) }
assertEquals(listOf(DfuProtocolKind.LEGACY), protocols)
}
@Test
fun `LegacyObserved gives Secure fallback 1 session attempt after Legacy engages`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.LegacyObserved)
val attemptCounts = mutableListOf<Int>()
coordinator.execute { protocol, attempts ->
attemptCounts.add(attempts)
if (protocol == DfuProtocolKind.LEGACY) {
DfuUploadResult.Failure(RuntimeException("legacy protocol failed"), protocolEngaged = true)
} else {
DfuUploadResult.Success
}
}
assertEquals(
listOf(3, 1),
attemptCounts,
) // Legacy primary=LEGACY_SESSION_ATTEMPTS, Secure fallback=LIMITED_SESSION_ATTEMPTS
}
@Test
fun `Unknown gives Legacy primary 1 session attempt`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.Unknown)
val attemptCounts = mutableListOf<Int>()
coordinator.execute { _, attempts ->
attemptCounts.add(attempts)
DfuUploadResult.Success
}
assertEquals(listOf(1), attemptCounts) // LIMITED_SESSION_ATTEMPTS keeps speculative Unknown probes bounded
}
@Test
fun `SecureObserved skips Legacy fallback when Secure never engages`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.SecureObserved)
val protocols = mutableListOf<DfuProtocolKind>()
assertFailsWith<RuntimeException> {
coordinator.execute { protocol, _ ->
protocols.add(protocol)
DfuUploadResult.Failure(RuntimeException("secure connect failed"), protocolEngaged = false)
}
}
.also { thrown -> assertEquals("secure connect failed", thrown.message) }
assertEquals(listOf(DfuProtocolKind.SECURE), protocols)
}
@Test
fun `SecureObserved throws Secure error with Legacy suppressed when both fail`() = runTest {
val coordinator = DfuFallbackCoordinator(BootloaderDetection.SecureObserved)
val protocols = mutableListOf<DfuProtocolKind>()
assertFailsWith<RuntimeException> {
coordinator.execute { protocol, _ ->
protocols.add(protocol)
if (protocol == DfuProtocolKind.SECURE) {
DfuUploadResult.Failure(RuntimeException("secure protocol failed"), protocolEngaged = true)
} else {
DfuUploadResult.Failure(RuntimeException("legacy also failed"), protocolEngaged = false)
}
}
}
.also { thrown ->
assertEquals("secure protocol failed", thrown.message)
assertEquals(1, thrown.suppressedExceptions.size, "alternate error should be suppressed on primary")
assertEquals("legacy also failed", thrown.suppressedExceptions.first().message)
}
assertEquals(listOf(DfuProtocolKind.SECURE, DfuProtocolKind.LEGACY), protocols)
}
}