mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-09 12:55:45 -04:00
fix(dfu): Detect legacy bootloaders before secure fallback (#6079)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user