Skip to content

Commit 0902240

Browse files
committed
Add cobs_codec_kt: pure-Kotlin Android COBS/COBS-R library
A self-contained Gradle/AGP Android library (namespace dev.firechip.cobs) implementing COBS + COBS/R + framing, ported from the Dart package and byte-identical to it (verified by a 20000-vector differential test against the Python reference). 22 JUnit tests pass. Adds a release-aar CI workflow that builds the .aar and attaches it to a cobs_codec_kt-v* release, and a .pubignore so the Dart package tarball stays lean. Signed-off-by: Alexander Salas Bastidas <ajsb85@firechip.dev>
1 parent 1c4b32d commit 0902240

22 files changed

Lines changed: 1385 additions & 0 deletions

.github/workflows/release-aar.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Release AAR
2+
3+
on:
4+
push:
5+
tags:
6+
- "cobs_codec_kt-v*"
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
build-and-release:
14+
name: Build AAR and attach to release
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v7
18+
- uses: actions/setup-java@v4
19+
with:
20+
distribution: temurin
21+
java-version: "17"
22+
- uses: android-actions/setup-android@v3
23+
- name: Install SDK packages
24+
run: sdkmanager "platforms;android-35" "build-tools;35.0.0"
25+
- name: Build AAR and run unit tests
26+
working-directory: cobs_codec_kt
27+
run: ./gradlew :cobs:testDebugUnitTest :cobs:assembleRelease --console=plain
28+
- name: Stage artifact
29+
id: stage
30+
run: |
31+
version="${GITHUB_REF_NAME#cobs_codec_kt-v}"
32+
if [ "$version" = "$GITHUB_REF_NAME" ]; then version="dev"; fi
33+
mkdir -p dist
34+
cp cobs_codec_kt/cobs/build/outputs/aar/cobs-release.aar "dist/cobs_codec_kt-${version}.aar"
35+
echo "version=$version" >> "$GITHUB_OUTPUT"
36+
- name: Upload as workflow artifact
37+
uses: actions/upload-artifact@v4
38+
with:
39+
name: cobs_codec_kt-aar
40+
path: dist/*.aar
41+
- name: Attach to GitHub release
42+
if: startsWith(github.ref, 'refs/tags/')
43+
uses: softprops/action-gh-release@v2
44+
with:
45+
files: dist/*.aar
46+
name: cobs_codec_kt ${{ steps.stage.outputs.version }} (Android AAR)
47+
body: |
48+
Pure-Kotlin **COBS / COBS-R** for Android, packaged as an `.aar`.
49+
50+
Byte-identical to the Dart [`cobs_codec`](https://pub.dev/packages/cobs_codec)
51+
(validated by a 20,000-vector differential test against the reference).
52+
53+
Add to your app:
54+
```kotlin
55+
implementation(files("libs/cobs_codec_kt-${{ steps.stage.outputs.version }}.aar"))
56+
```

.pubignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Keep the published Dart package lean: the Kotlin/Android library is a
2+
# sibling project in this monorepo and is not part of the Dart package.
3+
cobs_codec_kt/

cobs_codec_kt/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.gradle/
2+
build/
3+
local.properties
4+
*.iml
5+
.idea/
6+
.kotlin/

cobs_codec_kt/README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# cobs_codec_kt
2+
3+
Pure-Kotlin **Consistent Overhead Byte Stuffing (COBS)** and **COBS/R** for
4+
Android, distributed as an `.aar`. It is the Kotlin sibling of the Dart
5+
[`cobs_codec`](https://pub.dev/packages/cobs_codec) package and produces
6+
byte-identical output (validated by 20,000-vector differential testing against
7+
the original reference implementation).
8+
9+
COBS encodes an arbitrary `ByteArray` into one that contains no zero (`0x00`)
10+
bytes, at a small, predictable cost (at most one extra byte per 254 bytes, plus
11+
one). That lets a single `0x00` reliably delimit packets on a byte stream such
12+
as a serial/UART, USB, or BLE link.
13+
14+
## Features
15+
16+
- **Basic COBS** and **COBS/R (Reduced)** encode/decode (`Cobs`, `Cobsr`).
17+
- **Stream framing** for `0x00`-delimited links: `CobsFraming.frame` /
18+
`unframe`, and the incremental `CobsStreamDecoder` (reassembles packets across
19+
arbitrary chunk boundaries, with a `maxFrameLength` guard).
20+
- **Zero dependencies**, pure Kotlin, no Android framework APIs used in the
21+
logic. `minSdk 21`, `compileSdk 35`.
22+
23+
## Install
24+
25+
The `.aar` is attached to each
26+
[GitHub release](https://github.com/firechip/cobs_codec/releases). Download
27+
`cobs_codec_kt-<version>.aar` into your app's `libs/` directory and add:
28+
29+
```kotlin
30+
dependencies {
31+
implementation(files("libs/cobs_codec_kt-1.0.0.aar"))
32+
}
33+
```
34+
35+
## Usage
36+
37+
```kotlin
38+
import dev.firechip.cobs.Cobs
39+
import dev.firechip.cobs.Cobsr
40+
import dev.firechip.cobs.CobsFraming
41+
import dev.firechip.cobs.CobsStreamDecoder
42+
43+
// Encode / decode a single packet.
44+
val encoded = Cobs.encode(byteArrayOf(0x11, 0x00, 0x22)) // [0x02,0x11,0x02,0x22]
45+
val decoded = Cobs.decode(encoded) // [0x11,0x00,0x22]
46+
47+
// COBS/R often avoids the trailing overhead byte for small messages.
48+
Cobsr.encode("12345".toByteArray()) // "51234" bytes
49+
50+
// Frame a packet for a delimited link, then split a buffer back into packets.
51+
val frame = CobsFraming.frame(byteArrayOf(0x11, 0x00, 0x22)) // ... trailing 0x00
52+
val packets = CobsFraming.unframe(frame)
53+
54+
// Decode a live serial stream whose chunks do not align with frame boundaries.
55+
val rx = CobsStreamDecoder(maxFrameLength = 4096)
56+
serialPort.onBytes { chunk -> rx.feed(chunk).forEach(::handlePacket) }
57+
```
58+
59+
Invalid encoded input throws `CobsDecodeException`.
60+
61+
## Build
62+
63+
Requires JDK 17 and the Android SDK (`compileSdk 35`).
64+
65+
```console
66+
cd cobs_codec_kt
67+
./gradlew :cobs:assembleRelease # -> cobs/build/outputs/aar/cobs-release.aar
68+
./gradlew :cobs:testDebugUnitTest # unit tests (golden vectors)
69+
```
70+
71+
## License
72+
73+
MIT (c) 2026 Alexander Salas Bastidas ([Firechip](https://firechip.dev)). See
74+
the repository [LICENSE](../LICENSE).

cobs_codec_kt/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
plugins {
2+
id("com.android.library") version "8.7.3" apply false
3+
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
4+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
3+
plugins {
4+
id("com.android.library")
5+
id("org.jetbrains.kotlin.android")
6+
}
7+
8+
android {
9+
namespace = "dev.firechip.cobs"
10+
compileSdk = 35
11+
12+
defaultConfig {
13+
minSdk = 21
14+
}
15+
16+
buildTypes {
17+
release {
18+
isMinifyEnabled = false
19+
}
20+
}
21+
22+
compileOptions {
23+
sourceCompatibility = JavaVersion.VERSION_17
24+
targetCompatibility = JavaVersion.VERSION_17
25+
}
26+
27+
testOptions {
28+
unitTests.all {
29+
it.useJUnit()
30+
}
31+
}
32+
}
33+
34+
kotlin {
35+
compilerOptions {
36+
jvmTarget = JvmTarget.JVM_17
37+
}
38+
}
39+
40+
dependencies {
41+
testImplementation("junit:junit:4.13.2")
42+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest />
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
package dev.firechip.cobs
5+
6+
/**
7+
* Basic Consistent Overhead Byte Stuffing (COBS).
8+
*
9+
* COBS encodes an arbitrary [ByteArray] into one that contains no zero
10+
* (`0x00`) bytes, at a small and predictable cost (at most one extra byte per
11+
* 254 bytes, plus one). That lets a single `0x00` reliably delimit packets on a
12+
* byte stream. See Cheshire & Baker, "Consistent Overhead Byte Stuffing",
13+
* IEEE/ACM Transactions on Networking, Vol. 7, No. 2, April 1999.
14+
*/
15+
public object Cobs {
16+
17+
/**
18+
* Encodes [input] with basic COBS, returning a zero-free [ByteArray].
19+
*
20+
* Encoding never fails: any sequence of bytes is encodable. The empty input
21+
* encodes to `[0x01]`.
22+
*/
23+
@JvmStatic
24+
public fun encode(input: ByteArray): ByteArray {
25+
val srcLen = input.size
26+
if (srcLen == 0) return byteArrayOf(0x01)
27+
28+
// Worst-case output size; the actual output is a prefix of this buffer.
29+
val dst = ByteArray(maxEncodedLength(srcLen))
30+
var codeIndex = 0
31+
var writeIndex = 1
32+
var code = 1
33+
var readIndex = 0
34+
35+
while (true) {
36+
val b = input[readIndex++].toInt() and 0xFF
37+
if (b == 0) {
38+
dst[codeIndex] = code.toByte()
39+
codeIndex = writeIndex++
40+
code = 1
41+
if (readIndex >= srcLen) break
42+
} else {
43+
dst[writeIndex++] = b.toByte()
44+
code++
45+
// Terminate before the 0xFF split so a chunk of exactly 254
46+
// non-zero bytes does not emit a spurious trailing block.
47+
if (readIndex >= srcLen) break
48+
if (code == 0xFF) {
49+
dst[codeIndex] = code.toByte()
50+
codeIndex = writeIndex++
51+
code = 1
52+
}
53+
}
54+
}
55+
dst[codeIndex] = code.toByte()
56+
57+
return dst.copyOf(writeIndex)
58+
}
59+
60+
/**
61+
* Decodes basic-COBS-encoded [input], returning the original bytes.
62+
*
63+
* The empty input decodes to an empty array. Input should be a single
64+
* encoded packet with no surrounding `0x00` delimiter bytes.
65+
*
66+
* @throws CobsDecodeException if [input] contains a `0x00` byte or a length
67+
* code points past the end of the input.
68+
*/
69+
@JvmStatic
70+
public fun decode(input: ByteArray): ByteArray {
71+
val srcLen = input.size
72+
if (srcLen == 0) return ByteArray(0)
73+
74+
val out = ByteArray(srcLen)
75+
var writeIndex = 0
76+
var index = 0
77+
78+
while (true) {
79+
val code = input[index].toInt() and 0xFF
80+
if (code == 0) {
81+
throw CobsDecodeException("zero byte in COBS input", index)
82+
}
83+
index++
84+
val blockEnd = index + code - 1
85+
val copyEnd = if (blockEnd < srcLen) blockEnd else srcLen
86+
while (index < copyEnd) {
87+
val b = input[index].toInt() and 0xFF
88+
if (b == 0) {
89+
throw CobsDecodeException("zero byte in COBS input", index)
90+
}
91+
out[writeIndex++] = b.toByte()
92+
index++
93+
}
94+
if (blockEnd > srcLen) {
95+
throw CobsDecodeException(
96+
"length code points past end of input",
97+
blockEnd - code,
98+
)
99+
}
100+
if (blockEnd < srcLen) {
101+
if (code < 0xFF) out[writeIndex++] = 0
102+
} else {
103+
break
104+
}
105+
}
106+
107+
return out.copyOf(writeIndex)
108+
}
109+
110+
/** See the top-level [encodingOverhead]. */
111+
@JvmStatic
112+
public fun encodingOverhead(sourceLength: Int): Int =
113+
dev.firechip.cobs.encodingOverhead(sourceLength)
114+
115+
/** See the top-level [maxEncodedLength]. */
116+
@JvmStatic
117+
public fun maxEncodedLength(sourceLength: Int): Int =
118+
dev.firechip.cobs.maxEncodedLength(sourceLength)
119+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
package dev.firechip.cobs
5+
6+
/**
7+
* Thrown when decoding fails because the input is not a valid COBS (or COBS/R)
8+
* encoded byte sequence.
9+
*
10+
* A valid COBS stream never contains a zero byte, and every length code must
11+
* point to a valid position within the input. Decoding throws this when either
12+
* invariant is violated: a zero (`0x00`) byte appears in the input, or a length
13+
* code claims more bytes than remain (basic COBS only; COBS/R interprets that
14+
* same situation as its reduced final block).
15+
*
16+
* [offset] is the index of the offending byte within the encoded input, or
17+
* `-1` when unknown.
18+
*/
19+
public class CobsDecodeException @JvmOverloads constructor(
20+
message: String,
21+
public val offset: Int = -1,
22+
) : IllegalArgumentException(message)

0 commit comments

Comments
 (0)