A Rust port of the Google Messages "Messages for web" protocol client —
the client half of the open-source mautrix-gmessages bridge. It handles
pairing, the long-poll receive stream, and sending. The crate compiles to
a native .so and is exposed to Kotlin / Java through UniFFI bindings
(package uniffi.rcs_engine).
src/ crate source — protocol, crypto, the FFI surface (ffi.rs)
proto/ the Google Messages .proto definitions
generated/ the generated UniFFI Kotlin bindings (rcs_engine.kt)
build.rs prost + uniffi build orchestration
build.sh cargo-ndk cross-compile helper
Cargo.toml crate manifest + release size profile
In short: install a Rust toolchain, the Android NDK, and cargo-ndk, then from
this directory run:
ANDROID_NDK_HOME=/path/to/ndk ./build.sh
Most modern phones are 64-bit ARM. If arm64-v8a is your only target,
build just that one ABI and skip the rest:
ANDROID_NDK_HOME=/path/to/ndk \
cargo ndk -t arm64-v8a -o jniLibs-out build --release
The host app uses uniffi.rcs_engine.RustClient (construct it from a
paired RustSession, then connect / sendText / fetchMessages /
downloadMedia / …). The full method surface is in
generated/uniffi/uniffi/rcs_engine/rcs_engine.kt.
This section shows what goes where in an Android app. The examples use the
generated Kotlin package uniffi.rcs_engine.
After building, copy each ABI into the matching Android jniLibs directory:
rcs-engine/jniLibs-out/arm64-v8a/librcs_engine.so
-> your-app/app/src/main/jniLibs/arm64-v8a/librcs_engine.so
rcs-engine/generated/uniffi/uniffi/rcs_engine/rcs_engine.kt
-> your-app/app/src/main/java/uniffi/rcs_engine/rcs_engine.kt
Repeat the .so copy for every ABI your APK supports. The generated binding
imports JNA and Kotlin coroutines, so the app module must include an
Android-compatible JNA AAR and kotlinx-coroutines-core. Use versions compatible
with the rest of the host app rather than copying an arbitrary version number
from this repository.
Do not edit rcs_engine.kt by hand. Regenerate it whenever the Rust API changes,
and ship it with .so files produced from the same revision.
The engine does not invent or fetch pairing credentials. The host app's pairing
flow must persist these fields, decode any Base64 values back to bytes, and put
the mapping in its session repository—for example,
app/src/main/java/com/example/rcs/RcsSessionRepository.kt:
import uniffi.rcs_engine.RustSession
fun SavedPairing.toRustSession() = RustSession(
tachyonAuthToken = tachyonAuthToken,
tokenTtlSeconds = tokenTtlSeconds,
aesKey = aesKey, // exactly 32 bytes
hmacKey = hmacKey, // exactly 32 bytes
mobileDeviceProto = mobileDeviceProto, // serialized Device protobuf
browserDeviceProto = browserDeviceProto, // may be empty for an old session
cookies = cookies,
refreshKeyPkcs8 = refreshKeyPkcs8, // may be empty for an old session
destRegistrationId = destRegistrationId // may be empty for an old session
)SavedPairing is the host app's own persisted model; replace its property names
with the names used by that app. Invalid key lengths or malformed device/session
data cause RustClient connection calls to throw LibgmException.
Create a file such as
app/src/main/java/com/example/rcs/RcsEventReceiver.kt, implement every callback,
and replace each comment with the app's database, UI-state, notification, or
attachment-storage call:
import uniffi.rcs_engine.*
class RcsEventReceiver : DeveloperEventSink {
override fun onTextReceived(event: TextMessageEvent) {
// messages.upsert(event.message.messageId, event.text, event.message)
}
override fun onPhotoReceived(event: MediaMessageEvent) {
// mediaQueue.accept(MediaKind.PHOTO, event)
}
override fun onVideoReceived(event: MediaMessageEvent) {
// mediaQueue.accept(MediaKind.VIDEO, event)
}
override fun onAudioReceived(event: MediaMessageEvent) {
// mediaQueue.accept(MediaKind.AUDIO, event)
}
override fun onFileReceived(event: MediaMessageEvent) {
// mediaQueue.accept(MediaKind.FILE, event)
}
override fun onReactionReceived(event: ReactionEvent) {
// reactions.replaceSnapshot(event.messageId, event.emoji, event.participantIds)
}
override fun onTypingIndicator(event: TypingIndicatorEvent) {
// typing.set(event.conversationId, event.participantNumber, event.isTyping)
}
override fun onDeliveryReceipt(message: MessageEnvelope) {
// messages.markDelivered(message.messageId)
}
override fun onReadReceipt(message: MessageEnvelope) {
// messages.markRead(message.messageId)
}
override fun onMessageStatus(message: MessageEnvelope) {
// messages.updateStatus(message.messageId, message.status)
}
override fun onPhoneNotResponding() {
// connectionState.setPhoneReachable(false)
}
override fun onPhoneRespondingAgain() {
// connectionState.setPhoneReachable(true)
}
override fun onPairEvent(route: Int) {
// pairingEvents.accept(route)
}
override fun onAccountChanged(account: String, enabled: Boolean) {
// accounts.update(account, enabled)
}
override fun onRawEvent(
action: Int,
decryptedData: ByteArray?,
unencryptedData: ByteArray?,
isOld: Boolean
) {
// Optional forward-compatibility hook for events not typed by this API.
}
}Callbacks are not a UI-thread contract. Move database or UI work onto the host
app's own coroutine dispatcher. Message callbacks are snapshots and can repeat
as status changes, so upsert by event.message.messageId instead of inserting
duplicates. Ignore or specially reconcile events whose isOld flag is true.
Put the client in the app component that owns messaging connectivity, such as a
foreground service or process-scoped repository—not in an Activity that is
destroyed on rotation. For example,
app/src/main/java/com/example/rcs/RcsConnection.kt:
import uniffi.rcs_engine.RustClient
import uniffi.rcs_engine.RustSession
class RcsConnection(session: RustSession) {
private val events = RcsEventReceiver()
val client = RustClient(session)
suspend fun start() {
client.connectDeveloper(events)
}
suspend fun stop() {
client.disconnect()
client.close()
}
}Keep both RcsConnection and RcsEventReceiver strongly referenced for the
whole connection lifetime. Call start() from the service/repository coroutine,
catch LibgmException there, and call stop() during final teardown.
All send methods are suspend functions. Call them from the host app's existing
view-model or service coroutine, not directly from a blocking UI callback:
val rcs = connection.client
rcs.sendText("+15551234567", "Hello")
rcs.sendTextGroup(listOf("+15551234567", "+15557654321"), "Hello group")
rcs.sendPhoto(
recipientPhones = listOf("+15551234567"),
caption = "Trip photo",
fileName = "photo.jpg",
mimeType = "image/jpeg",
data = photoBytes
)
rcs.sendVideo(
recipientPhones = listOf("+15551234567"),
caption = "",
fileName = "clip.mp4",
mimeType = "video/mp4",
data = videoBytes
)
rcs.sendVoiceMessage(
recipientPhones = listOf("+15551234567"),
fileName = "voice.m4a",
mimeType = "audio/mp4",
data = recordedAudioBytes
)
rcs.sendTypingStarted(conversationId)
rcs.sendTypingStopped(conversationId)
rcs.sendReaction(messageId, "👍", 1) // 1 add, 2 remove, 3 switch
rcs.markRead(conversationId, messageId)Pass the real MIME type and encoded file bytes from the app's picker or recorder.
Photo, video, audio, file, attachment, and MMS methods accept the entire payload
as a ByteArray, so read large files off the main thread and enforce the host
app's attachment-size policy before calling them.
Typing, reactions, and read receipts use IDs, not phone numbers. Store
conversationId and messageId from incoming MessageEnvelope values or the
app's conversation/message store, then pass those exact IDs back to the send
method. Send sendTypingStopped when the composer becomes empty, loses focus,
the message is sent, or the screen closes.
MediaMessageEvent.inlineData contains the file when it was delivered inline.
When it is empty and mediaId is present, download and decrypt the attachment
from a coroutine:
suspend fun bytesFor(event: MediaMessageEvent, client: RustClient): ByteArray =
if (event.inlineData.isNotEmpty()) {
event.inlineData
} else {
client.downloadMedia(event.mediaId, event.decryptionKey)
}Persist the result using fileName and mimeType. onPhotoReceived,
onVideoReceived, onAudioReceived, and onFileReceived already separate the
attachment categories, so the host app does not need to inspect protobuf frames.
Outgoing operations are equally explicit:
| Feature | RustClient entry point |
|---|---|
| One-to-one text | sendText |
| Group text | sendTextGroup |
| Photo | sendPhoto |
| Video | sendVideo |
| Audio file | sendAudio |
| Voice recording | sendVoiceMessage |
| File | sendFile |
| Generic attachment | sendAttachment |
| MMS | sendMms |
| Add/remove/switch reaction | sendReaction |
| Typing started/stopped | sendTypingStarted / sendTypingStopped |
| Mark read | markRead |
Incoming operations are delivered through these individual callbacks:
| Incoming feature | DeveloperEventSink callback |
|---|---|
| Text | onTextReceived |
| Photo | onPhotoReceived |
| Video | onVideoReceived |
| Audio or voice recording | onAudioReceived |
| Other file | onFileReceived |
| Reaction snapshot | onReactionReceived |
| Typing started/stopped | onTypingIndicator (isTyping) |
| Delivered receipt | onDeliveryReceipt |
| Read/displayed receipt | onReadReceipt |
| Any message status change | onMessageStatus |
| Phone reachability | onPhoneNotResponding / onPhoneRespondingAgain |
| Account state | onAccountChanged |
| Unknown future event | onRawEvent |
Host verification covers typed incoming typing start/stop, outgoing typing request construction for both states, and separate incoming photo/video routing. The checked-in Kotlin binding is generated from the same verified Rust API. Android native compilation and live Google Messages delivery require separate integration verification by the consuming app.
Every protocol decision in this crate is a faithful port of
mautrix-gmessages's pkg/libgm (Go); that Go source is the reference.