Skip to content

About

Developer-friendly RCS engine with explicit APIs for messages, media, reactions, typing indicators, and receipts.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

9 Commits

Folders and files

Repository files navigation

rcs-engine — RCS (Google Messages) engine for third-party Android apps

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).

Layout

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

Building

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

Public API

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.

Developer integration API

This section shows what goes where in an Android app. The examples use the generated Kotlin package uniffi.rcs_engine.

1. Copy the native library and Kotlin binding into the app

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.

2. Convert the app's saved pairing record into RustSession

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.

3. Put incoming-event routing in one receiver

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.

4. Own the connection from a long-lived component

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.

5. Connect each composer action to its specific send method

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.

6. Download media received through a callback

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.

Endpoint map

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.

Source of truth

Every protocol decision in this crate is a faithful port of mautrix-gmessages's pkg/libgm (Go); that Go source is the reference.

About

Developer-friendly RCS engine with explicit APIs for messages, media, reactions, typing indicators, and receipts.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages