Skip to content

Commit 6c0c626

Browse files
committed
feat: add java.io framing streams and a coroutines Flow API
Zero-dep java.io adapters; Flow via compileOnly coroutines (no runtime dep).
1 parent a140b9c commit 6c0c626

6 files changed

Lines changed: 399 additions & 0 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ as a serial/UART, USB, or BLE link.
3434
`unframe`, and the incremental `CobsStreamDecoder` (reassembles packets across
3535
arbitrary chunk boundaries, with a `maxFrameLength` guard). Each takes a
3636
`sentinel` byte (default `0x00`) so frames can be delimited by any chosen byte.
37+
- **`java.io` stream adapters**`CobsFramedOutputStream.writeFrame` /
38+
`CobsFramedInputStream.readFrame` (plus `frames()` as a `Sequence`) wrap any
39+
`OutputStream` / `InputStream` to write and read self-delimiting frames. They
40+
use only `java.io`, so they add no dependency.
41+
- **Coroutines `Flow` hook**`Flow<ByteArray>.cobsFrames()` reassembles a flow
42+
of raw chunks into a flow of decoded packets. `kotlinx-coroutines` is a
43+
`compileOnly` dependency, so the published artifact stays free of any runtime
44+
dependency; the extension is available to consumers who already use coroutines.
3745
- **Zero dependencies**, pure Kotlin, no Android framework APIs in the logic.
3846
`minSdk 21`, `compileSdk 35`.
3947

@@ -122,6 +130,39 @@ CobsFraming.unframe(framed, sentinel = s) // [
122130
val rxAA = CobsStreamDecoder(maxFrameLength = 4096, sentinel = s)
123131
```
124132

133+
### `java.io` stream adapters
134+
135+
Wrap any `OutputStream` / `InputStream` to write and read self-delimiting frames
136+
(dependency-free — `java.io` only). `readFrame()` returns `null` at end of stream;
137+
`frames()` exposes the same reads as a `Sequence`.
138+
139+
```kotlin
140+
import dev.firechip.cobs.CobsFramedOutputStream
141+
import dev.firechip.cobs.CobsFramedInputStream
142+
143+
CobsFramedOutputStream(socket.outputStream).use { out ->
144+
out.writeFrame(byteArrayOf(0x11, 0x00, 0x22)) // encoded frame + 0x00 delimiter
145+
}
146+
147+
val input = CobsFramedInputStream(socket.inputStream) // reduced / sentinel optional
148+
for (packet in input.frames()) handlePacket(packet)
149+
```
150+
151+
### Coroutines `Flow`
152+
153+
`Flow<ByteArray>.cobsFrames()` reassembles a flow of raw chunks (however
154+
misaligned) into a flow of decoded packets. `kotlinx-coroutines` is a
155+
`compileOnly` dependency, so it adds nothing to the published artifact; add it to
156+
your own build to use this extension.
157+
158+
```kotlin
159+
import dev.firechip.cobs.cobsFrames
160+
161+
serialBytes // Flow<ByteArray> of raw reads
162+
.cobsFrames() // reduced / skipEmpty / sentinel optional
163+
.collect { packet -> handlePacket(packet) }
164+
```
165+
125166
Invalid encoded input throws `CobsDecodeException`.
126167

127168
## Build

cobs/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ kotlin {
5858
}
5959

6060
dependencies {
61+
// Coroutines back the Flow.cobsFrames extension only. It is compileOnly so the
62+
// published AAR/POM gains NO runtime dependency: consumers who use the Flow API
63+
// already have kotlinx-coroutines on their classpath, and those who don't pay
64+
// nothing. The test source set needs it (and coroutines-test) at runtime.
65+
compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
6166
testImplementation("junit:junit:4.13.2")
67+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
68+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
6269
}
6370

6471
publishing {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
package dev.firechip.cobs
5+
6+
import kotlinx.coroutines.flow.Flow
7+
import kotlinx.coroutines.flow.flow
8+
9+
/**
10+
* Reassembles a [Flow] of raw byte chunks — as delivered by a serial/UART, USB,
11+
* or BLE source whose reads do not align with frame boundaries — into a [Flow]
12+
* of decoded packets.
13+
*
14+
* Each collection drives a fresh [CobsStreamDecoder]: upstream chunks are fed to
15+
* it in order and every packet it completes is emitted downstream, so a frame
16+
* split across several chunks (or several frames in one chunk) is handled
17+
* transparently. The returned flow is cold and re-collectable.
18+
*
19+
* This mirrors [CobsFramedInputStream] for coroutine-based sources. Coroutines
20+
* is a `compileOnly` dependency of this library, so this extension is available
21+
* only to consumers who already depend on `kotlinx-coroutines-core`.
22+
*
23+
* @param reduced decode with COBS/R (reduced) instead of basic COBS.
24+
* @param skipEmpty drop empty frames instead of emitting empty arrays.
25+
* @param sentinel the delimiter byte (and the byte each frame avoids); defaults
26+
* to the `0x00` [COBS_DELIMITER] and must match the sender's sentinel.
27+
* @throws CobsDecodeException if a completed frame is not valid encoded data.
28+
*/
29+
public fun Flow<ByteArray>.cobsFrames(
30+
reduced: Boolean = false,
31+
skipEmpty: Boolean = true,
32+
sentinel: Byte = COBS_DELIMITER,
33+
): Flow<ByteArray> {
34+
val chunks = this
35+
return flow {
36+
val decoder = CobsStreamDecoder(
37+
reduced = reduced,
38+
skipEmpty = skipEmpty,
39+
sentinel = sentinel,
40+
)
41+
chunks.collect { chunk ->
42+
for (frame in decoder.feed(chunk)) {
43+
emit(frame)
44+
}
45+
}
46+
}
47+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
package dev.firechip.cobs
5+
6+
import java.io.ByteArrayOutputStream
7+
import java.io.FilterInputStream
8+
import java.io.FilterOutputStream
9+
import java.io.IOException
10+
import java.io.InputStream
11+
import java.io.OutputStream
12+
13+
/**
14+
* An [OutputStream] wrapper that writes whole packets as self-delimiting COBS
15+
* frames, for pushing packets onto a byte stream such as a serial/UART link.
16+
*
17+
* Each [writeFrame] call COBS-encodes a packet (COBS/R when [reduced] is true,
18+
* otherwise basic COBS) and writes the encoded bytes followed by the [sentinel]
19+
* delimiter. Because the encoding never emits the sentinel, the delimiter
20+
* unambiguously ends the frame. This is the streaming counterpart of
21+
* [CobsFraming.frame].
22+
*
23+
* The class extends [FilterOutputStream], so [flush] and [close] delegate to the
24+
* wrapped [out] stream; closing this stream closes the underlying one.
25+
*
26+
* @param out the underlying stream that framed bytes are written to.
27+
* @param reduced use COBS/R (reduced) encoding instead of basic COBS.
28+
* @param sentinel the delimiter byte (and the byte the encoding avoids); defaults
29+
* to the `0x00` [COBS_DELIMITER] and must match the reader's sentinel.
30+
*/
31+
public class CobsFramedOutputStream @JvmOverloads constructor(
32+
out: OutputStream,
33+
private val reduced: Boolean = false,
34+
private val sentinel: Byte = COBS_DELIMITER,
35+
) : FilterOutputStream(out) {
36+
37+
/**
38+
* Encodes [packet] and writes the resulting frame — the encoded bytes plus a
39+
* trailing [sentinel] delimiter — to the underlying stream.
40+
*
41+
* The bytes are written straight to the wrapped stream; call [flush] to push
42+
* them through a buffered stream. An empty [packet] still produces a
43+
* non-empty frame (a single length byte plus the delimiter).
44+
*
45+
* @throws IOException if the underlying stream fails to accept the bytes.
46+
*/
47+
@Throws(IOException::class)
48+
public fun writeFrame(packet: ByteArray) {
49+
val encoded = if (reduced) {
50+
Cobsr.encodeWithSentinel(packet, sentinel)
51+
} else {
52+
Cobs.encodeWithSentinel(packet, sentinel)
53+
}
54+
out.write(encoded)
55+
out.write(sentinel.toInt())
56+
}
57+
}
58+
59+
/**
60+
* An [InputStream] wrapper that reads self-delimiting COBS frames back into
61+
* packets, the reading counterpart of [CobsFramedOutputStream].
62+
*
63+
* [readFrame] consumes bytes up to the next [sentinel] delimiter (or end of
64+
* stream), decodes them (COBS/R when [reduced] is true, otherwise basic COBS)
65+
* and returns the recovered packet, or `null` once the stream is exhausted.
66+
* Empty frames — produced by a leading or consecutive delimiter — are skipped
67+
* when [skipEmpty] is true. [frames] exposes the same reads as a [Sequence].
68+
*
69+
* Bytes are pulled one at a time from the source, so wrap an unbuffered source
70+
* (a socket or file) in a [java.io.BufferedInputStream] for throughput. The
71+
* class extends [FilterInputStream], so [close] delegates to the wrapped
72+
* [source].
73+
*
74+
* @param source the underlying stream framed bytes are read from.
75+
* @param reduced decode with COBS/R (reduced) instead of basic COBS.
76+
* @param skipEmpty drop empty frames instead of returning empty arrays.
77+
* @param sentinel the delimiter byte (and the byte each frame avoids); defaults
78+
* to the `0x00` [COBS_DELIMITER] and must match the writer's sentinel.
79+
*/
80+
public class CobsFramedInputStream @JvmOverloads constructor(
81+
source: InputStream,
82+
private val reduced: Boolean = false,
83+
private val skipEmpty: Boolean = true,
84+
private val sentinel: Byte = COBS_DELIMITER,
85+
) : FilterInputStream(source) {
86+
87+
/**
88+
* Reads and decodes the next frame, returning the recovered packet, or `null`
89+
* at end of stream.
90+
*
91+
* Bytes are read until the [sentinel] delimiter or end of stream; the bytes
92+
* before it form the frame. A frame that ends at end of stream without a
93+
* delimiter (a truncated tail) is still decoded and returned. Empty frames
94+
* are skipped while [skipEmpty] is true, otherwise each yields an empty array.
95+
*
96+
* @throws IOException if the underlying stream fails.
97+
* @throws CobsDecodeException if a complete frame is not valid encoded data.
98+
*/
99+
@Throws(IOException::class)
100+
public fun readFrame(): ByteArray? {
101+
while (true) {
102+
val first = `in`.read()
103+
if (first == -1) return null
104+
if (first.toByte() == sentinel) {
105+
// Empty frame from a leading or consecutive delimiter.
106+
if (skipEmpty) continue
107+
return ByteArray(0)
108+
}
109+
val buffer = ByteArrayOutputStream()
110+
buffer.write(first)
111+
while (true) {
112+
val b = `in`.read()
113+
if (b == -1 || b.toByte() == sentinel) break
114+
buffer.write(b)
115+
}
116+
return decodeFrame(buffer.toByteArray())
117+
}
118+
}
119+
120+
/**
121+
* Returns a [Sequence] that yields each frame from [readFrame] until the
122+
* stream is exhausted. Iterating the sequence drives the same underlying
123+
* reads, so it is single-pass.
124+
*/
125+
public fun frames(): Sequence<ByteArray> = generateSequence { readFrame() }
126+
127+
private fun decodeFrame(frame: ByteArray): ByteArray =
128+
if (reduced) {
129+
Cobsr.decodeWithSentinel(frame, sentinel)
130+
} else {
131+
Cobs.decodeWithSentinel(frame, sentinel)
132+
}
133+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
package dev.firechip.cobs
5+
6+
import kotlinx.coroutines.flow.asFlow
7+
import kotlinx.coroutines.flow.flowOf
8+
import kotlinx.coroutines.flow.toList
9+
import kotlinx.coroutines.runBlocking
10+
import kotlinx.coroutines.test.runTest
11+
import org.junit.Assert.assertArrayEquals
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Test
15+
16+
/** Reassembles flows of raw chunks into decoded frames via [cobsFrames]. */
17+
class FlowTest {
18+
19+
@Test
20+
fun reassemblesMisalignedChunks() = runTest {
21+
val wire = cat(
22+
CobsFraming.frame(bytes(0x01, 0x02)),
23+
CobsFraming.frame(bytes(0x00, 0xFF)),
24+
CobsFraming.frame(bytes(0x2A)),
25+
)
26+
// Cut the wire at boundaries that fall in the middle of frames.
27+
val cuts = intArrayOf(0, 1, 6, 7, wire.size)
28+
val chunks = (0 until cuts.size - 1).map { wire.copyOfRange(cuts[it], cuts[it + 1]) }
29+
30+
val frames = flowOf(*chunks.toTypedArray()).cobsFrames().toList()
31+
assertEquals(3, frames.size)
32+
assertArrayEquals(bytes(0x01, 0x02), frames[0])
33+
assertArrayEquals(bytes(0x00, 0xFF), frames[1])
34+
assertArrayEquals(bytes(0x2A), frames[2])
35+
}
36+
37+
@Test
38+
fun reducedAndSentinelByteAtATime() = runTest {
39+
val s = 0xAA.toByte()
40+
val packets = listOf(ascii("12345"), bytes(0x11, 0x00, 0x22), bytes(0x2A))
41+
val wire = cat(
42+
*Array(packets.size) {
43+
CobsFraming.frame(packets[it], reduced = true, sentinel = s)
44+
},
45+
)
46+
// One byte per chunk is the most misaligned drip possible.
47+
val chunks = wire.map { byteArrayOf(it) }
48+
49+
val frames = chunks.asFlow().cobsFrames(reduced = true, sentinel = s).toList()
50+
assertEquals(packets.size, frames.size)
51+
for (k in packets.indices) assertArrayEquals(packets[k], frames[k])
52+
}
53+
54+
@Test
55+
fun skipEmptyFalseKeepsEmptyFrames() = runBlocking {
56+
val wire = cat(bytes(0x00), CobsFraming.frame(bytes(0x42)), bytes(0x00, 0x00))
57+
58+
val frames = flowOf(wire).cobsFrames(skipEmpty = false).toList()
59+
assertEquals(4, frames.size)
60+
assertTrue(frames[0].isEmpty())
61+
assertArrayEquals(bytes(0x42), frames[1])
62+
assertTrue(frames[2].isEmpty())
63+
assertTrue(frames[3].isEmpty())
64+
}
65+
}

0 commit comments

Comments
 (0)