feat: implement XModem file transfers and enhance BLE connection robustness (#4959)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run

This commit is contained in:
James Rich 2026-03-30 22:49:31 -05:00 committed by GitHub
parent ae4465d7c8
commit c75c9b34d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1100 additions and 120 deletions

View file

@ -16,7 +16,12 @@
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
@ -26,7 +31,13 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.duplicated_public_key_title
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.key_verification_final_title
import org.meshtastic.core.resources.key_verification_request_title
import org.meshtastic.core.resources.key_verification_title
import org.meshtastic.core.resources.low_entropy_key_title
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@ -38,6 +49,11 @@ class FromRadioPacketHandlerImpl(
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
) : FromRadioPacketHandler {
// Application-scoped coroutine context for suspend work (e.g. getStringSuspend).
// This @Single lives for the entire app lifetime, so the SupervisorJob is never cancelled.
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
@Suppress("CyclomaticComplexMethod")
override fun handleFromRadio(proto: FromRadio) {
val myInfo = proto.my_info
@ -50,9 +66,15 @@ class FromRadioPacketHandlerImpl(
val moduleConfig = proto.moduleConfig
val channel = proto.channel
val clientNotification = proto.clientNotification
val deviceUIConfig = proto.deviceuiConfig
val fileInfo = proto.fileInfo
val xmodemPacket = proto.xmodemPacket
when {
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
// deviceuiConfig arrives immediately after my_info (STATE_SEND_UIDATA). It carries
// the device's display, theme, node-filter, and other UI preferences.
deviceUIConfig != null -> router.value.configHandler.handleDeviceUIConfig(deviceUIConfig)
metadata != null -> router.value.configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
router.value.configFlowManager.handleNodeInfo(nodeInfo)
@ -64,17 +86,48 @@ class FromRadioPacketHandlerImpl(
config != null -> router.value.configHandler.handleDeviceConfig(config)
moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig)
channel != null -> router.value.configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
notificationManager.dispatch(
Notification(
title = getString(Res.string.client_notification),
message = clientNotification.message,
category = Notification.Category.Alert,
),
)
packetHandler.removeResponse(0, complete = false)
}
fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo)
xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket)
clientNotification != null -> handleClientNotification(clientNotification)
}
}
private fun handleClientNotification(cn: ClientNotification) {
serviceRepository.setClientNotification(cn)
scope.handledLaunch {
val inform = cn.key_verification_number_inform
val request = cn.key_verification_number_request
val verificationFinal = cn.key_verification_final
val (title, type) =
when {
inform != null -> {
Logger.i { "Key verification inform from ${inform.remote_longname}" }
Pair(getStringSuspend(Res.string.key_verification_title), Notification.Type.Info)
}
request != null -> {
Logger.i { "Key verification request from ${request.remote_longname}" }
Pair(getStringSuspend(Res.string.key_verification_request_title), Notification.Type.Info)
}
verificationFinal != null -> {
Logger.i { "Key verification final from ${verificationFinal.remote_longname}" }
Pair(getStringSuspend(Res.string.key_verification_final_title), Notification.Type.Info)
}
cn.duplicated_public_key != null -> {
Logger.w { "Duplicated public key notification received" }
Pair(getStringSuspend(Res.string.duplicated_public_key_title), Notification.Type.Warning)
}
cn.low_entropy_key != null -> {
Logger.w { "Low entropy key notification received" }
Pair(getStringSuspend(Res.string.low_entropy_key_title), Notification.Type.Warning)
}
else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info)
}
notificationManager.dispatch(
Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert),
)
packetHandler.removeResponse(0, complete = false)
}
}
}

View file

@ -37,6 +37,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
@ -152,6 +153,8 @@ class MeshConfigFlowManagerImpl(
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearLocalModuleConfig()
radioConfigRepository.clearDeviceUIConfig()
radioConfigRepository.clearFileManifest()
}
}
@ -165,6 +168,11 @@ class MeshConfigFlowManagerImpl(
newNodes.add(info)
}
override fun handleFileInfo(info: FileInfo) {
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
}
override fun triggerWantConfig() {
connectionManager.value.startConfigOnly()
}

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
@ -82,4 +83,8 @@ class MeshConfigHandlerImpl(
serviceRepository.setConnectionProgress("Channels (${index + 1})")
}
}
override fun handleDeviceUIConfig(config: DeviceUIConfig) {
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
}
}

View file

@ -184,12 +184,15 @@ class MeshConnectionManagerImpl(
handshakeTimeout = scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.w { "Handshake stall detected! Retrying Stage $stage." }
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
// the stall is on our side, the retry will be dropped and the reconnect below
// will trigger instead — which is the right recovery in that case.
Logger.w { "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled." }
action()
// Recursive timeout for one more try
delay(HANDSHAKE_TIMEOUT)
delay(HANDSHAKE_RETRY_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Resetting connection." }
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
onConnectionChanged(ConnectionState.Disconnected)
}
}
@ -267,16 +270,24 @@ class MeshConnectionManagerImpl(
}
}
}
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
override fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set device time now that the full node picture is ready. Sending this during Stage 1
// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
// Proactively seed the session passkey. The firmware embeds session_passkey in every
// admin *response* (wantResponse=true), but set_time_only has no response. A get_owner
// request is the lightest way to trigger a response and populate the passkey cache so
// that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
commandSender.sendAdmin(myNodeNum, wantResponse = true) { AdminMessage(get_owner_request = true) }
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
@ -289,7 +300,6 @@ class MeshConnectionManagerImpl(
reportConnection()
val myNodeNum = nodeManager.myNodeNum ?: 0
// Request history
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
@ -329,6 +339,11 @@ class MeshConnectionManagerImpl(
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 30.seconds
// Shorter window for the retry attempt: if the device genuinely didn't receive the
// first want_config_id the retry completes within a few seconds. Waiting another 30s
// before reconnecting just delays recovery unnecessarily.
private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
private const val EVENT_NUM_NODES = "num_nodes"

View file

@ -258,7 +258,10 @@ class MeshDataHandlerImpl(
private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
u.session_passkey.let { commandSender.setSessionPasskey(it) }
// Guard against clearing a valid passkey: firmware always embeds the key in every
// admin response, but a missing (default-empty) field must not reset the stored value.
val incomingPasskey = u.session_passkey
if (incomingPasskey.size > 0) commandSender.setSessionPasskey(incomingPasskey)
val fromNum = packet.from
u.get_module_config_response?.let {

View file

@ -26,6 +26,7 @@ import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.XModemManager
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
@Suppress("LongParameterList")
@ -38,6 +39,7 @@ class MeshRouterImpl(
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
private val mqttManagerLazy: Lazy<MqttManager>,
private val actionHandlerLazy: Lazy<MeshActionHandler>,
private val xmodemManagerLazy: Lazy<XModemManager>,
) : MeshRouter {
override val dataHandler: MeshDataHandler
get() = dataHandlerLazy.value
@ -60,6 +62,9 @@ class MeshRouterImpl(
override val actionHandler: MeshActionHandler
get() = actionHandlerLazy.value
override val xmodemManager: XModemManager
get() = xmodemManagerLazy.value
override fun start(scope: CoroutineScope) {
dataHandler.start(scope)
configHandler.start(scope)

View file

@ -0,0 +1,161 @@
/*
* Copyright (c) 2025-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/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.XModemFile
import org.meshtastic.core.repository.XModemManager
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.XModem
import kotlin.concurrent.Volatile
/**
* XModem-CRC receiver state machine.
*
* Protocol summary (device = sender, Android = receiver):
* - SOH / STX data block with seq, CRC-CCITT-16, payload; reply ACK or NAK
* - EOT end of transfer; reply ACK, emit assembled file
* - CAN sender cancelled; reset state
*
* CRC algorithm: CRC-CCITT (poly 0x1021, init 0x0000), same as the Meshtastic firmware.
*/
@Single
class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManager {
private val _fileTransferFlow =
MutableSharedFlow<XModemFile>(
replay = 0,
extraBufferCapacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val fileTransferFlow = _fileTransferFlow.asSharedFlow()
// --- mutable state ---
// Thread-safety contract: [handleIncomingXModem] is called sequentially from
// [FromRadioPacketHandlerImpl.handleFromRadio] on a single IO coroutine. The
// [setTransferName] and [cancel] calls originate from UI/ViewModel coroutines
// and are guarded by @Volatile for visibility. Concurrent block processing is
// not possible because the firmware sends one XModem packet at a time and waits
// for ACK/NAK before sending the next.
@Volatile private var transferName = ""
@Volatile private var expectedSeq = INITIAL_SEQ
private val blocks = mutableListOf<ByteArray>()
override fun setTransferName(name: String) {
transferName = name
}
override fun handleIncomingXModem(packet: XModem) {
when (packet.control) {
XModem.Control.SOH,
XModem.Control.STX,
-> handleDataBlock(packet)
XModem.Control.EOT -> handleEot()
XModem.Control.CAN -> {
Logger.w { "XModem: CAN received — transfer cancelled" }
reset()
}
else -> Logger.w { "XModem: unexpected control byte ${packet.control}, ignoring" }
}
}
private fun handleDataBlock(packet: XModem) {
val seq = packet.seq and 0xFF
val data = packet.buffer.toByteArray()
if (!validateCrc(data, packet.crc16)) {
Logger.w { "XModem: CRC error on block $seq (expected seq=$expectedSeq) — NAK" }
sendControl(XModem.Control.NAK)
return
}
when (seq) {
expectedSeq -> {
blocks.add(data)
expectedSeq = (expectedSeq % MAX_SEQ) + 1
Logger.d { "XModem: block $seq OK, total=${blocks.size} blocks" }
sendControl(XModem.Control.ACK)
}
// Duplicate: sender did not receive our previous ACK; re-ACK without buffering again.
(expectedSeq - 1 + MAX_SEQ_PLUS_ONE) % MAX_SEQ_PLUS_ONE -> {
Logger.d { "XModem: duplicate block $seq — re-ACK" }
sendControl(XModem.Control.ACK)
}
else -> {
Logger.w { "XModem: unexpected seq $seq (expected $expectedSeq) — NAK" }
sendControl(XModem.Control.NAK)
}
}
}
private fun handleEot() {
Logger.i { "XModem: EOT — transfer complete (${blocks.size} blocks, name='$transferName')" }
sendControl(XModem.Control.ACK)
val raw = blocks.fold(ByteArray(0)) { acc, block -> acc + block }
// Strip trailing CTRL-Z padding that XModem senders add to fill the last block.
var end = raw.size
while (end > 0 && raw[end - 1] == CTRLZ) end--
val trimmed = if (end == raw.size) raw else raw.copyOf(end)
_fileTransferFlow.tryEmit(XModemFile(name = transferName, data = trimmed))
reset()
}
override fun cancel() {
Logger.i { "XModem: cancelling transfer" }
sendControl(XModem.Control.CAN)
reset()
}
private fun sendControl(control: XModem.Control) {
packetHandler.sendToRadio(ToRadio(xmodemPacket = XModem(control = control)))
}
private fun reset() {
expectedSeq = INITIAL_SEQ
blocks.clear()
transferName = ""
}
// CRC-CCITT: polynomial 0x1021, initial value 0x0000 (XModem variant)
private fun validateCrc(data: ByteArray, expectedCrc: Int): Boolean =
calculateCrc16(data) == (expectedCrc and 0xFFFF)
private fun calculateCrc16(data: ByteArray): Int {
var crc = 0
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
repeat(BITS_PER_BYTE) { crc = if (crc and 0x8000 != 0) (crc shl 1) xor CRC_POLY else crc shl 1 }
}
return crc and 0xFFFF
}
companion object {
private const val INITIAL_SEQ = 1
private const val MAX_SEQ = 255
private const val MAX_SEQ_PLUS_ONE = 256
private const val CTRLZ = 0x1A.toByte()
private const val CRC_POLY = 0x1021
private const val BITS_PER_BYTE = 8
}
}

View file

@ -17,6 +17,8 @@
package org.meshtastic.core.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import org.koin.core.annotation.Single
import org.meshtastic.core.datastore.ChannelSetDataSource
@ -30,6 +32,8 @@ import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
@ -103,6 +107,30 @@ open class RadioConfigRepositoryImpl(
moduleConfigDataSource.setLocalModuleConfig(config)
}
// DeviceUIConfig is session-scoped data received fresh in every handshake — no persistence needed.
private val _deviceUIConfigFlow = MutableStateFlow<DeviceUIConfig?>(null)
override val deviceUIConfigFlow: Flow<DeviceUIConfig?> = _deviceUIConfigFlow.asStateFlow()
override suspend fun setDeviceUIConfig(config: DeviceUIConfig) {
_deviceUIConfigFlow.value = config
}
override suspend fun clearDeviceUIConfig() {
_deviceUIConfigFlow.value = null
}
// FileInfo manifest is session-scoped: accumulated during STATE_SEND_FILEMANIFEST, cleared on each new handshake.
private val _fileManifestFlow = MutableStateFlow<List<FileInfo>>(emptyList())
override val fileManifestFlow: Flow<List<FileInfo>> = _fileManifestFlow.asStateFlow()
override suspend fun addFileInfo(info: FileInfo) {
_fileManifestFlow.value += info
}
override suspend fun clearFileManifest() {
_fileManifestFlow.value = emptyList()
}
/** Flow representing the combined [DeviceProfile] protobuf. */
override val deviceProfileFlow: Flow<DeviceProfile> =
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {

View file

@ -0,0 +1,144 @@
/*
* 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/>.
*/
package org.meshtastic.core.data.manager
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.XModem
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class XModemManagerImplTest {
private lateinit var packetHandler: PacketHandler
private lateinit var xmodemManager: XModemManagerImpl
@BeforeTest
fun setup() {
packetHandler = mock<PacketHandler> { every { sendToRadio(any<ToRadio>()) } returns Unit }
xmodemManager = XModemManagerImpl(packetHandler)
}
private fun calculateExpectedCrc(data: ByteArray): Int {
var crc = 0
for (byte in data) {
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
repeat(8) { crc = if (crc and 0x8000 != 0) (crc shl 1) xor 0x1021 else crc shl 1 }
}
return crc and 0xFFFF
}
@Test
fun `successful transfer emits file and ACKs blocks`() = runTest {
val payload1 = "Hello, ".encodeToByteArray()
val payload2 = "Meshtastic!".encodeToByteArray()
xmodemManager.setTransferName("test.txt")
xmodemManager.fileTransferFlow.test {
// Send Block 1
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 1,
crc16 = calculateExpectedCrc(payload1),
buffer = payload1.toByteString(),
),
)
// Send Block 2
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 2,
crc16 = calculateExpectedCrc(payload2),
buffer = payload2.toByteString(),
),
)
// EOT
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.EOT))
val file = awaitItem()
assertEquals("test.txt", file.name)
assertEquals("Hello, Meshtastic!", file.data.decodeToString())
verify(exactly(3)) { packetHandler.sendToRadio(any<ToRadio>()) }
}
}
@Test
fun `ignores bad CRC and replies NAK`() = runTest {
val payload1 = "Bad CRC payload".encodeToByteArray()
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 1,
crc16 = 0xBAD, // intentionally bad
buffer = payload1.toByteString(),
),
)
verify(exactly(1)) { packetHandler.sendToRadio(any<ToRadio>()) }
}
@Test
fun `handles CAN and resets state`() = runTest {
xmodemManager.setTransferName("bad.txt")
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.CAN))
// No control sent back for CAN by the device, just resets.
// If we cancel locally, we send CAN. Wait, the test is for receiving CAN.
// So nothing should be sent, but state should reset.
// Let's verify no ACK/NAK sent when receiving CAN.
verify(exactly(0)) { packetHandler.sendToRadio(any<ToRadio>()) }
}
@Test
fun `removes CTRLZ padding from end of file`() = runTest {
val payload = byteArrayOf(0x48, 0x69, 0x1A, 0x1A) // "Hi" + CTRL-Z padding
xmodemManager.setTransferName("padded.txt")
xmodemManager.fileTransferFlow.test {
xmodemManager.handleIncomingXModem(
XModem(
control = XModem.Control.SOH,
seq = 1,
crc16 = calculateExpectedCrc(payload),
buffer = payload.toByteString(),
),
)
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.EOT))
val file = awaitItem()
val expected = byteArrayOf(0x48, 0x69) // "Hi"
assertTrue(expected.contentEquals(file.data))
}
}
}