btw: MoQ is under active development. The APIs and protocols are still evolving and will change. Most of this documentation is AI generated until things get more stable.

Skip to content

dev.moq:moq

The Kotlin Multiplatform module for Media over QUIC.

A single Maven coordinate that publishes JVM and Android variants. Gradle metadata picks the right one for your target, so there are no per-platform artifacts to track.

Install

kotlin
// build.gradle.kts
dependencies {
    implementation("dev.moq:moq:0.2.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}

Native binaries are bundled for:

  • Android: arm64-v8a, armeabi-v7a, x86_64
  • JVM: Linux x86_64 + aarch64, macOS x86_64 + aarch64, Windows x86_64

Android uses JNI (jniLibs/), desktop JVM uses JNA (resource-classpath layout). Both are bundled in the same AAR/JAR.

Connect

kotlin
import uniffi.moq.MoqClient
import uniffi.moq.MoqOriginProducer

// Wire an origin as both publish source and consume sink for the
// typical full-duplex client. Set just one side for a subscribe-only
// or publish-only client.
val origin = MoqOriginProducer()
val client = MoqClient()
client.setPublish(origin)
client.setConsume(origin)

val session = client.connect("https://relay.example.com")

For development against a relay with a self-signed certificate, configure the client before connecting:

kotlin
val client = MoqClient()
client.setTlsDisableVerify(true)
client.setBind("127.0.0.1:0")
client.setPublish(origin)
client.setConsume(origin)
val session = client.connect("https://localhost:4443")

When you're done, signal graceful shutdown to the peer:

kotlin
session.shutdown()  // alias for cancel(0u)

A server can reject the connection on auth grounds: MoqException.Unauthorized (HTTP 401) or MoqException.Forbidden (HTTP 403). These are terminal: retrying without new credentials won't help, so handle them separately from a transient transport failure. Use the isAuth helper to catch both:

kotlin
import dev.moq.isAuth

try {
    val session = client.connect("https://relay.example.com")
} catch (e: MoqException) {
    if (e.isAuth) {
        // Prompt for credentials; don't reconnect.
    }
}

Subscribe

kotlin
import dev.moq.*
import kotlinx.coroutines.flow.collect

val consumer = origin.consume()
val announced = consumer.announced("demos/")

announced.announcements().collect { announcement ->
    val catalog = announcement.broadcast().subscribeCatalog()
    catalog.updates().collect { update ->
        println("catalog: $update")
    }
}

Publish

kotlin
import dev.moq.*
import uniffi.moq.MoqBroadcastProducer

val broadcast = MoqBroadcastProducer()
val audio = broadcast.publishMedia("opus", opusInitBytes)

origin.publish("my-stream", broadcast)

audio.writeFrame(payload, timestampUs = 0u)
audio.writeFrame(payload, timestampUs = 20_000u)
audio.finish()
broadcast.finish()

On-demand raw tracks

Use a dynamic broadcast when subscribers should be able to request raw tracks that are not published yet:

kotlin
import dev.moq.*
import uniffi.moq.MoqBroadcastProducer

val broadcast = MoqBroadcastProducer()
val dynamic = broadcast.dynamic()

origin.publish("events", broadcast)

dynamic.requestedTracks().collect { track ->
    if (track.name() == "alerts") {
        track.writeFrame("ready".encodeToByteArray())
        track.finish()
    } else {
        track.abort(404)
    }
}

JSON tracks

For JSON payloads, publish and subscribe with the framing handled for you, in one of two modes. Snapshot (lossy) carries one value updated over time; a subscriber only sees the latest. Stream (lossless) is an ordered append-log where every record is preserved. Values cross as JSON strings; serialize with your JSON library of choice.

kotlin
import dev.moq.*
import uniffi.moq.MoqBroadcastProducer
import uniffi.moq.MoqJsonConfig
import uniffi.moq.MoqJsonStreamConfig

// Snapshot: each update supersedes the last.
val config = MoqJsonConfig(deltaRatio = 8u, compression = true)
val status = broadcast.publishJson("status", config)
status.update("""{"state":"live"}""")

val broadcastConsumer = broadcast.consume()
val consumer = broadcastConsumer.subscribeJson("status", config)
consumer.values().collect { value -> println(value) }

// Stream: every record is delivered in order.
val events = broadcast.publishJsonStream("events", MoqJsonStreamConfig(compression = false))
events.append("""{"event":"started"}""")

compression must match on the producer and subscriber. In snapshot mode, deltaRatio of 0 disables merge-patch deltas (every change is a fresh snapshot).

Cancellation

The wrapper exposes consumers as Kotlin Flows. Cancelling the collector's coroutine scope calls cancel() on the native side via the wrapper's onCompletion hook, releasing resources promptly:

kotlin
val job = launch {
    mediaConsumer.frames().collect { frame ->
        process(frame)
    }
}

// Later:
job.cancel()  // releases native resources

Local development

To build and run the JVM tests locally:

bash
just kt check

This builds moq-ffi for the host arch, regenerates the UniFFI Kotlin bindings, drops the host cdylib into the JNA resource layout, and runs gradle :moq:jvmTest. It needs cargo, a JDK, and Gradle, all of which ship in the nix develop shell; run it from there. To only regenerate the checked-in bindings without compiling or testing (for an environment that intentionally lacks Gradle), use just kt generate.

Android targets are opt-in via -Pandroid.enabled=true. Local builds without the Android SDK still produce a working JVM variant.

See also

Licensed under MIT or Apache-2.0