ChromaHash ships an Android binding under bindings/uniffi/: the
dependency-free Rust core called natively across the JNI boundary and packaged as an AAR,
rather than the pure-JVM Kotlin implementation. This guide explains the design and how to build,
test, and consume it; bindings/uniffi/README.md is the
hands-on reference for the build/publish commands.
The Kotlin implementation (kotlin/) is correct, spec-compatible, and the most
ergonomic choice if you only decode a handful of placeholders. But it is pure-JVM scalar code:
decoding runs a per-pixel inverse DCT, a per-channel gamut clip, and an OKLab→sRGB conversion
entirely on the JVM heap, with no SIMD. The JVM has no portable SIMD story, so the planned SIMD work
(#3) only targets Rust, Swift, and C#.
On the client, decode is the hot path — encoding is normally done server-side, and an app
decodes placeholders at render time, often many per screen. The Rust core
(rust/, crate chromahash v0.6.0, zero dependencies) is portable, compiles
natively for Android's ARM64 devices, and is the implementation that will gain SIMD. Bridging it to
Kotlin/Java over JNI gives Android the fast decoder while keeping a Kotlin-shaped API.
The binding uses UniFFI (Mozilla) for the binding layer.
It auto-generates idiomatic Kotlin from the Rust crate — minimal boilerplate, proven in Firefox for
Android. Because decode is a one-shot call returning a small buffer (≤ 32×32 RGBA at the default tier), per-call FFI
overhead is irrelevant. A lower-level jni-crate alternative is covered in
§11.
The core crate stays untouched and zero-dependency. A separate binding crate depends on it and carries the UniFFI dependency:
chromahash (rust/) # core, zero deps — unchanged
▲
│ path dependency
│
chromahash-uniffi (bindings/uniffi/) # crate-type = ["lib", "cdylib", "staticlib"]; depends on uniffi
│
│ cargo-ndk → one .so per Android ABI + uniffi-bindgen → io/chromahash/ffi/*.kt
▼
Android library module (bindings/uniffi/android/) # AAR: jniLibs/<abi>/*.so + generated Kotlin + JNA
▼
Your Android app # io.chromahash.ffi.ChromaHash
The core crate's zero-dependency property (rust/Cargo.toml) is preserved — uniffi lives only in
the binding crate. The lib crate-type (beyond the doc-era cdylib/staticlib) lets the
spec-vector integration tests link the crate (§9).
3. The API (bindings/uniffi/src/lib.rs)
UniFFI's proc-macro (UDL-less) mode is used: Rust is annotated directly, no .udl file. A
ChromaHash object mirrors the pure-Kotlin
ChromaHash class, plus the Gamut enum and
the DecodeResult / RgbaColor records. uniffi.toml sets the generated package to
io.chromahash.ffi, so it does not collide with the pure-Kotlin chromahash package.
Two deliberate differences from a naïve 1:1 mapping, both at the FFI boundary:
- Every fallible entry point throws. A panic across the FFI boundary is
undefined behaviour, so the binding validates first and returns
Result<_, ChromaHashError>in Rust →@Throws(ChromaHashException::class)in Kotlin. That coversfromBytes(InvalidData— bad version, reserved tier code, set reserved bit, or a length its header does not imply) andencode/encodeWithQuality/encodeBatch(InvalidDimensions,InvalidLength,InvalidTier). The variants mirror the C ABI's status codes, so the taxonomy is the same in every binding. - Record integers are signed.
DecodeResult.width/heightandRgbaColor.r/g/b/aare declaredi32(→ KotlinInt), matching the pure-Kotlin API and Android'sBitmap/ARGB call sites. Only size parameters (encode'sw/h,decodeCapped'smaxW/maxH) remainu32→ KotlinUInt. UniFFI mapsVec<u8>→ByteArrayeither way.
Pinned versions.
uniffi = "0.31"(the doc was originally drafted against 0.28; 0.31 is the edition-2024-clean line). TheVec<u8>→ByteArrayand the signed-i32mappings above are verified against this release.
All commands are mise tasks (mise tasks lists them); full details in
bindings/uniffi/README.md.
Host-only (no Android toolchain) — the enforced correctness gate:
mise run test:android # spec vectors through the binding (byte-exact)
mise run lint:android # clippy -D warnings
mise run build:android:crate # host build of the crateThe AAR (requires the Android NDK + cargo-ndk + Rust Android targets):
mise run build:android:aar # cargo ndk (all ABIs) → uniffi-bindgen → gradle assembleReleaseassembleRelease's preBuild depends on the cargoNdkBuild → generateUniffiBindings Gradle
tasks, so that single command cross-compiles arm64-v8a, armeabi-v7a, x86_64, x86 into
jniLibs/<abi>/libchromahash_uniffi.so, regenerates the Kotlin, and produces
android/build/outputs/aar/chromahash-android-release.aar. Drop ABIs you don't ship to shrink the
AAR; arm64-v8a covers essentially all modern physical devices.
The generated Kotlin runtime depends on JNA (the Android @aar artifact,
net.java.dev.jna:jna:5.14.0@aar) and loads the .so itself — you don't call System.loadLibrary.
Publishing is wired via the vanniktech maven-publish
plugin to Maven Central and GitHub Packages as io.github.visualcommons:chromahash-android
(plus publishToMavenLocal for local dev); a vX.Y.Z tag publishes both channels via the
release-android workflow. See
RELEASING.md and the
binding README.
import android.graphics.Bitmap
import io.chromahash.ffi.ChromaHash
import java.nio.ByteBuffer
/** Decode a ChromaHash into a Bitmap for use as a placeholder. */
fun decodeToBitmap(hashBytes: ByteArray): Bitmap {
val hash = ChromaHash.fromBytes(hashBytes) // throws on a malformed hash
val result = hash.decode()
val bitmap = Bitmap.createBitmap(result.width, result.height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(result.rgba))
return bitmap
}Pixel order. Decode output is RGBA (bytes
R, G, B, Aper pixel).Bitmap.Config.ARGB_8888stores pixels in native memory asR, G, B, A, socopyPixelsFromBufferconsumes the decoded bytes directly. If your placeholders carry partial alpha, note thatARGB_8888Bitmaps are premultiplied by default — premultiply the RGBA yourself or callbitmap.setPremultipliedas appropriate. Verify on your target device.
For a solid-color placeholder you can skip the full decode and use the DC color only (fields are
Int, so no .toInt() is needed):
val c = ChromaHash.fromBytes(hashBytes).averageColor()
val argb = (c.a shl 24) or (c.r shl 16) or (c.g shl 8) or c.bThe native API mirrors the pure-Kotlin implementation 1:1, modulo the two FFI-boundary differences in §3:
Pure Kotlin (chromahash) |
Native (UniFFI, io.chromahash.ffi) |
|---|---|
ChromaHash.encode(w, h, rgba, gamut) |
ChromaHash.encode(w, h, rgba, gamut) — @Throws |
ChromaHash.fromBytes(bytes) |
ChromaHash.fromBytes(bytes) — @Throws |
chromaHash.decode(): DecodeResult |
chromaHash.decode(): DecodeResult |
chromaHash.averageColor(): RgbaColor |
chromaHash.averageColor(): RgbaColor |
chromaHash.hash: ByteArray |
chromaHash.asBytes(): ByteArray |
| — | chromaHash.decodeCapped(maxW, maxH) |
Gamut enum |
Gamut enum |
Swapping the import (chromahash.ChromaHash → io.chromahash.ffi.ChromaHash) and handling the
encode / fromBytes exceptions is the bulk of the migration.
The binding is bit-exact with every other implementation. The enforced gate is a Rust integration
test, bindings/uniffi/tests/spec_vectors.rs, which
runs the spec test vectors in spec/test-vectors/ through the binding
wrappers and asserts exact output — no NDK/SDK required, so it runs in mise run test (hk
pre-push) and the ci-android check job:
integration-encode.json— encode pixels at each tier, compare the hash (+ average color).integration-decode.json— decode a hash, compare RGBA byte-for-byte.
The contract is defined in the spec: binary format in §3, decode algorithm in §11, average color in §11.2, decode output sizing in §8.2. Because the binding just forwards to the core crate, correctness reduces to the core crate's own test suite plus this marshalling check. An optional host-JVM end-to-end check through the generated Kotlin is described in the binding README.
Use the native path when you decode many placeholders or decode on the render hot path:
- One-shot decode amortizes JNI/UniFFI marshalling to noise; the work is the inner DCT + color-conversion loops, where native scalar already beats the JVM and avoids GC churn from per-pixel float math.
- ARM64 plus the upcoming SIMD work (#3) widens the gap further.
- The
.sois loaded once per process.
Prefer the pure-Kotlin implementation when decode volume is low and simplicity matters:
- No NDK toolchain, no native build step in CI.
- Smaller artifact — each shipped ABI adds a copy of the
.so. - Pure-JVM portability (any JVM target, not just Android ABIs).
.github/workflows/ci-android.yml (triggered on
bindings/uniffi/**) has two jobs:
check—cargo fmt --check,clippy -D warnings, andcargo testagainst the binding crate. No NDK; this is the required correctness gate.aar— installs the Rust Android targets +cargo-ndk, points at the runner's NDK (ANDROID_NDK_LATEST_HOME), and runsgradle assembleRelease, uploading the AAR artifact.
Releases are separate: pushing a vX.Y.Z tag triggers
.github/workflows/release-android.yml, which builds
the AAR the same way and publishes it to Maven Central + GitHub Packages (see
RELEASING.md).
If you want zero codegen and no JNA runtime dependency, write the bridge by hand with the
jni crate: #[unsafe(no_mangle)] pub extern "system" fn Java_io_chromahash_ffi_ChromaHash_decode(...)
functions that marshal jbyteArray ↔ &[u8] and call the core crate directly. This gives the
lowest possible call overhead and full control, at the cost of maintaining JNI signatures and
manual memory marshalling by hand. cargo-ndk (§4) is identical for
this path — only the binding layer and Kotlin external fun declarations differ. The crate already
builds a staticlib to support this.