Note: This project is developed with AI assistance.
A Rust implementation of a Matrix RTC (Real-Time Communication) client SDK: MSC4143 call-membership signalling, per-participant media-key exchange, and a transport-agnostic media layer — one Rust codebase behind Kotlin/Swift bindings (UniFFI) and a signalling-only WebAssembly build for the web.
The centrepiece of the project. To the host, a call is a set of participants with observable frame streams (microphone, camera, screenshare) plus per-stream constraints (visibility, rendered size, low-bandwidth mode). Everything underneath is hidden in Rust:
- No LiveKit types on the API surface. LiveKit is one implementation of
the
MediaTransporttrait (crates/matrix-rtc-transport); future transports (P2P, WebTransport) slot into the same model. - MSC4195 multi-SFU built in: each member publishes to their own focus
and the connection pool (
matrix-rtc-transport) maintains one connection per distinct focus in the call, with backoff, roster union, and identity mapping — so Kotlin/Swift never reimplement it. - Constraints drive simulcast subscribe-side: tell the engine how a tile is rendered and it picks the right layer, pauses off-screen streams, and re-applies settings across reconnects.
- Frame E2EE throughout: media keys are exchanged over Olm-encrypted to-device messages and applied per participant across all connections.
let client = RtcClient::new(Arc::new(SdkMatrixBackend::new(matrix_sdk_client)));
let room = client.room(room_id, RoomOptions::default()).await?;
room.seeded().await;
let call = room.join_call(CallJoinOptions::new().slot("standup")).await?; // m.call#standup
// Key exchange, the engine, and the connection to our own focus.
let media = attach_livekit(
&call,
room.backend().clone(),
LiveKitAttachOptions {
format: Default::default(), // must match RoomOptions::format
http: None,
auto_subscribe: true,
stability: Default::default(),
},
)
.await?
.media;
let camera = media
.engine
.publish(PublishOptions::camera(VideoSourceConfig { width: 1280, height: 720 }))
.await?;
// `capture_video` is synchronous and latest-frame-wins: a plain thread does.
let (frames, mut ffmpeg) = ffmpeg_frames("clip.mp4")?; // examples/publish_video.rs
std::thread::spawn(move || {
for frame in frames {
// Errors once the publication is gone (left, slot closed, ...).
if camera.capture_video(frame).is_err() {
break;
}
}
});
// Receive: one task per remote camera, logging what arrives. Ctrl-C
// leaves; the loop ends on `Ended`, which a leave, the slot or the room
// closing all produce.
let engine = media.engine;
let mut events = engine.subscribe_events();
let hang_up = tokio::signal::ctrl_c();
tokio::pin!(hang_up);
let mut left = false;
loop {
let event = tokio::select! {
_ = &mut hang_up, if !left => {
left = true;
call.leave(Default::default()).await?;
continue;
}
event = events.recv() => event,
};
match event {
Ok(CallEvent::StreamStarted {
member_id,
kind: MediaStreamKind::Camera,
}) => {
let Some(mut frames) = engine
.remote_track(&member_id, MediaStreamKind::Camera)
.and_then(|track| track.video_frames())
else {
continue;
};
// How the tile is rendered: the engine picks the simulcast
// layer that fits, and pauses the stream while not visible.
engine.set_constraints(
&member_id,
MediaStreamKind::Camera,
MediaConstraints {
detail: VideoDetail::Dimensions(Dimensions {
width: 320,
height: 180,
}),
..Default::default()
},
);
println!("{member_id}: camera started");
tokio::spawn(async move {
let mut received = 0u64;
while let Some(frame) = frames.next().await {
// A real host renders `frame.buffer`'s I420 planes here.
received += 1;
if received.is_multiple_of(100) {
let (w, h) = (frame.buffer.width, frame.buffer.height);
println!("{member_id}: {received} frames, now {w}x{h}");
}
}
});
}
Ok(CallEvent::Ended { reason }) => {
println!("call ended: {reason:?}");
break;
}
Ok(_) | Err(RecvError::Lagged(_)) => {}
Err(RecvError::Closed) => break,
}
}
ffmpeg.kill()?;examples/publish_video.rs
is this sample as a runnable program, login and sync included. LiveKitCall::join(&room, LiveKitCallOptions)
does the client → room → call → attach steps in one call over a matrix_sdk::Room.
The same model crosses the FFI boundary — on Android/Kotlin (media-enabled build, see below):
val room = client.room(roomId, FfiRoomOptions())
val call = room.joinCall(params)
val session = connectMediaSession(call, config)
val stream = session.videoStream(memberId, FfiStreamKind.CAMERA)!!
while (true) {
val frame = stream.next() ?: break
// safe copy: frame.data(plane) — or zero-copy while holding the frame:
// frame.planePtr(plane) / frame.stride(plane) / frame.planeLen(plane)
}Audio frames cross by value; video frames are handles with both safe-copy and
zero-copy plane access, latest-frame-wins so slow consumers drop frames
instead of lagging. See ARCHITECTURE.md for the full
design, the module docs in crates/matrix-rtc-ffi/src/media/mod.rs for the
host-app integration flow, and
crates/matrix-rtc-call-sdk/README.md
for a runnable two-client example against the local backend.
crates/matrix-rtc-transport: how media flows, for any MatrixRTC application on the core — theMediaTransportcontract, frames, constraints, the media key handler, the MSC4195 multi-SFU connection pool (pool), and the pure MSC4195 control plane (livekit). No transport IO, no LiveKit client; compiles for wasm32.crates/matrix-rtc-call-sdk: the call SDK hosts use — the call's media model over that contract (participants, tiles, the unified event stream, theCallEngine),attach_media, and behind featuresattach_livekitand the high-levelLiveKitCall::joinfacade with the examples and e2e test. Fully unit-tested against a fake transport.crates/matrix-rtc-livekit: MSC4195 LiveKit transport — SFU token exchange, per-participant frame E2EE, and theMediaTransportimplementation. Knows no call. Native-only (pulls inlibwebrtc).crates/matrix-rtc-core: the per-room MSC4143 core (BaseRtcRoom, aSlotSessionper slot), the MSC4143/MSC4354 event conversion boundary, and the feeder that subscribes through a host'sMatrixBackendand feeds it (BaseRtcClientopens rooms that feed themselves), andcompat, the pre-2026 MatrixRTC membership formats Element Call still speaks.crates/matrix-rtc-call: the host-facing objects (RtcClient→RtcRoom→RtcSession/RtcCall) and the call application — reactions, raised hand, MSC4075 ringing — and the transport choice. No Matrix SDK, so it tests in seconds against no git dependencies.crates/matrix-rtc-matrix-sdk:SdkMatrixBackend, theMatrixBackendover matrix-rust-sdk. No LiveKit.crates/matrix-rtc-ffi: UniFFI-based Kotlin/Swift bindings — the client → room → call objects always, plus the call SDK's media (matrix-rtc-call-sdkwith LiveKit) behind themediacargo feature (default off, keeps the slim artifact libwebrtc-free).crates/matrix-rtc-wasm: wasm bindings for the web (signalling only — browsers keep using livekit-js for media).web: browser-first JavaScript package and wasm-pack build/test scaffold.mobile/android: Gradle library module for the AAR;Package.swift+Sources/MatrixRtc(repo root): the Swift package over the released xcframework;mobile/ios: its local-development manifest and build output.demo/backend: self-contained MatrixRTC backend (Synapse + lk-jwt-service + two LiveKit SFUs for multi-focus testing, docker compose) used by the e2e call test on CI and for local development.
Tagged releases (v*) ship the media variant for both platforms; see
RELEASING.md for how they are cut and
mobile/PACKAGING.md for the full
integration notes.
// Android — Gradle (GitHub Packages Maven repository, or Maven Central once enabled)
implementation("io.element.android:matrix-rtc-android:<version>")// iOS — Xcode: File > Add Package Dependencies…, this repository's URL, a v* tag.
// Then add -ObjC to the app target's "Other Linker Flags".
To build the Android AAR and iOS XCFramework with one command each:
# Prerequisites (the bindings generator is the in-repo uniffi-bindgen crate)
cargo install cargo-ndk
# Add required Rust targets
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
# Slim (signalling-only) artifacts
./scripts/build-android-aar.sh
./scripts/build-ios-xcframework.sh
# Media-enabled artifacts (frame streams + publishing; statically links libwebrtc)
make build-android-media
make build-ios-mediaSee mobile/PACKAGING.md for detailed documentation —
including what changes with the media variant (binary sizes, libwebrtc.jar
on Android, the required -ObjC linker flag on iOS), integration guides, and
CI/CD setup. mobile/README.md covers what a host app has to
do (native loading, logging, keep-alive, diagnosing dead media), and
CHANGELOG.md tracks what changed for integrators — read its
Breaking section before bumping the SDK.
cd web
npm run build
npm run test:vitestThe web/ package uses wasm-pack to generate browser-first bindings under web/pkg/.
If you prefer to generate bindings manually without building the full AAR/XCFramework:
cargo build -p matrix-rtc-ffi --release
# Generate Swift bindings
uniffi-bindgen generate \
--library target/release/libmatrix_rtc_ffi.dylib \
--language swift \
--out-dir ./bindings/swift
# Generate Kotlin bindings
uniffi-bindgen generate \
--library target/release/libmatrix_rtc_ffi.so \
--language kotlin \
--out-dir ./bindings/kotlincargo check
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo testEnd-to-end call test against the local backend stack — two clients exchange E2EE-encrypted tone audio and pattern video, in both single-focus and two-foci (multi-SFU) scenarios, including a constraints pause/resume pass (see demo/backend/README.md; also run by CI on every PR):
make backend-up
make test-e2eThe media FFI smoke tests (no backend needed, compiles libwebrtc):
make test-ffi-mediaBefore committing any change, run:
cargo check
cargo fmt
cargo clippy --all-targets --all-features -- -D warnings
cargo testThen run binding tasks when relevant:
- If changes touch
crates/matrix-rtc-wasm/**orweb/**:
cd web && npm run build
cd web && npm run test:vitest- If changes touch
crates/matrix-rtc-ffi/**,mobile/**, orscripts/build-*.sh:
./scripts/build-android-aar.sh
./scripts/build-ios-xcframework.sh(./scripts/build-ios-xcframework.sh is macOS-only; add MEDIA=1 when the
change touches the media feature.)
If a required platform/toolchain is not available locally, document the skip reason in the PR description and ensure the corresponding CI job passes before merge.
Finally, record anything a host integrator would notice in CHANGELOG.md — new or changed API, behaviour changes, and especially breaking changes to the command-sender callbacks, which surface as compile errors in the host app.
Copyright (c) 2026 Element Creations Ltd.
This software is dual licensed by Element Creations Ltd (Element). It can be used either:
(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR
(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to).
Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses.
See LICENSE and LICENSE-COMMERCIAL.