R Relay ← Back to relay docs

Integrating Relay into an existing Android app

This is the complete path for a team that already has an Android app and wants to add chat + calling to it — not a new demo app. Everything you need is on this page; the platform's own packages/android/README.md has the full API reference if you need more than what's shown here.

For iOS or web, see integration-ios.md / integration-web.md instead. A shorter, cross-platform overview lives at integration.md.

The mental model

Relay is a separate service your existing backend talks to, plus a client library your Android app embeds. It does not replace your login system, your user database, or your backend — it has no idea who your users are until you tell it, on every request, via a short-lived token your own backend mints. Three actors:

Nothing here requires a rewrite. RelayChat and RelayCallOverlay are ordinary composables you place inside a screen you already have; if you already have your own chat UI, skip to Headless below and keep it.

Step 1 — Create a project and get your keys

Once, against the Relay service (ask whoever runs it for the URL, or run service/ yourself — see service/README.md):

curl -s -X POST https://relay.example.com/projects \
  -H 'content-type: application/json' \
  -d '{"name":"My App"}'
# {
#   "publicKey": "pk_5f1e…",   ← ships inside your Android app, safe to embed
#   "secretKey": "sk_9c2a…"    ← backend only, shown exactly once — store it in your secrets manager now
# }

publicKey identifies your project to Relay and is fine baked into your app's APK — it grants nothing by itself. secretKey mints user tokens for anyone in your project; it must never reach client code, an APK, or git. If you lose it, rotate it (POST /projects/me/keys) rather than trying to recover it — it's stored as a hash.

Step 2 — Add a token endpoint to your existing backend

This is the one piece of backend work, and the only place Relay touches your existing auth. Add a route your already-signed-in users can call:

POST /api/relay-token          (your backend, behind your existing session/auth middleware)
  → looks up the current user from YOUR session (however you already do that)
  → calls Relay:  POST /users/token  { "externalId": "<your user id>", "displayName": "...", "avatarUrl": "..." }
                  Authorization: Bearer sk_9c2a…
  → returns { "userToken": "...", "expiresAt": "..." } to your Android client

externalId is your user id — whatever you already use as a primary key. Relay never invents its own identity; every userId your app sees on the wire (message senders, call participants, presence) is that same id. Tokens expire in ~15 minutes; relay-core calls this endpoint again automatically when one expires, so this route just needs to exist and be fast. sample-apps/web-demo/token-server.mjs is a complete, 60-line reference implementation of this exact endpoint (framework-agnostic — the shape is identical regardless of which client calls it). Full details: service/README.md.

Step 3 — Add the SDK to your app

Install — until the modules are published, add them as a composite build or includeBuild("path/to/relay-sdk/packages/android"), then in your existing app module's build.gradle.kts:

implementation(project(":relay-ui"))     // chat UI — pulls in relay-core
implementation(project(":relay-call"))   // add only if you want calling too

Requires minSdk 26, Kotlin 2.1, Compose BOM 2025.01.

Initialize — once per signed-in user, next to wherever your app already builds its other network clients:

val relay = RelayClient(RelayConfig(
    baseUrl = "https://relay.example.com",
    publicKey = "pk_5f1e…",
    tokenProvider = { myExistingBackendClient.fetchRelayToken() },  // calls Step 2's endpoint
    packageId = BuildConfig.APPLICATION_ID,                        // optional, see Security below
))

Call relay.connect() where your app already handles "became foreground" / sign-in (onStart/after login), and relay.goToBackground() / relay.disconnect() at the matching background/sign-out points — the store resyncs by itself on every reconnect (the server never replays missed events), so there's no missed-message bookkeeping to add yourself.

Mount the UI — inside whatever screen already hosts your chat/conversation list today:

setContent {
    MaterialTheme {                 // your app's existing theme — RelayChat inherits its colors/type
        RelayChat(client = relay)   // list → thread → composer, as one composable
    }
}

RelayChat reads the ambient MaterialTheme.colorScheme/.typography your app already installs, so dropping it into an existing themed app already gets you most of the way to matching your brand — see Theming (Step 6) for the parts that still need an explicit override.

Headless, if you already have chat UI and only want the data layer:

relay.connect()
relay.chat.loadConversations()
val convo = relay.chat.openConversation("user-42")          // your own user id
relay.chat.sendMessage(convo.id, "hello")
relay.chat.state.collect { snapshot -> /* your own UI reads conversations, threads, typing, presence */ }

Step 4 — Calling (1:1 and group, one API)

Calling is a separate module (relay-call) so a chat-only integration doesn't pull in WebRTC. One call-start API handles both 1:1 and group calls — the SDK looks at the conversation you pass it and shows the right layout itself:

val calls = CallCenter(context, relay)                 // once per client
Box { RelayChat(relay); RelayCallOverlay(calls) }       // banner + full-screen UI, 1:1 or group automatically
// in a thread header:
CallButtons(calls, conversation)
// or trigger it yourself:
calls.start(conversation, CallType.VIDEO)

Group calls (up to 6 participants) render as a grid — 2 stacked, 3 as 2-over-1, up to 6 as three rows of 2 — with per-tile name badges, mute indicators, tap-to-fullscreen, and a per-participant "mute for me" control that only affects what you hear locally, without touching what anyone else hears. Request RECORD_AUDIO (and CAMERA for video) before starting a call.

Ringing when the app is fully closed (FCM)

Once your project has FCM credentials (set from the admin panel), the service sends a high-priority FCM data message for incoming calls (and a cancel when the ring ends):

// Application.onCreate()
RelayCallHost.configure(this, MainActivity::class.java) { RelayClient(relayConfig) }
// use RelayCallHost.client() / RelayCallHost.callCenter() in your UI so state is shared

// your FirebaseMessagingService
override fun onMessageReceived(m: RemoteMessage) {
    if (RelayCallPush.onMessage(this, m.data)) return   // Relay handled a call push
    // ... your own notifications
}
override fun onNewToken(token: String) = RelayCallPush.onNewToken(token)
// after sign-in: RelayCallPush.registerCurrentToken(); on sign-out: RelayCallPush.unregisterCurrentToken()

RelayCallService rings, vibrates, shows a full-screen incoming-call notification with Answer/Decline, and keeps the process alive for the length of the call. Ask for POST_NOTIFICATIONS on API 33+. Chat messages arrive as ordinary FCM notification messages with data.relay = "chat_message" and conversationId for deep-linking.

Step 5 — Wire your own "add participants" picker

Group chats need a way to pick who to add. Relay does not ship a directory of your users — it has no idea who they are beyond the ids that show up in tokens — so this is always a callback into your own app. Two ways to hook it in:

Your own picker UI entirely (fastest to wire, if you already have a contacts/user list screen) — return the ids yourself, in whatever UI you want:

MessageThread(client = relay, conversationId = id, onPickGroupMembers = { showMyOwnContactPicker() })

Relay's built-in picker, backed by your directory — you supply a search function, Relay renders the search/list/selection UI itself:

MessageThread(client = relay, conversationId = id, onSearchPeople = { query -> myUserDirectory.search(query) })

onSearchPeople is called once with an empty query when the screen opens (a starting list) and again as the user types; return RelayUsers (id, display name, avatar, online status) from whatever directory your app already has. If you supply both, onSearchPeople wins; if you supply neither, "Add people" is simply hidden.

Step 6 — Make it look like your app

Every color, most icons, and text sizing are overridable, with defaults equal to Relay's own look — so this step is optional, and doing nothing (or just wrapping in your existing MaterialTheme, per Step 3) is a fully supported choice.

RelayTheme(
    // .copy() on the Material-derived default lets you override just the colors you care about
    colors = RelayColors.fromMaterialTheme(MaterialTheme.colorScheme).copy(
        accent = MyBrandBlue,
        danger = MyBrandRed,
    ),
    icons = RelayIcons(micOn = myMicIcon, callEnd = myHangUpIcon),
    typography = RelayTypography(fontFamily = myBrandFont, fontScale = 1.1f),
) {
    RelayChat(relay)
    RelayCallOverlay(calls)
}

This covers chat and call screens uniformly — the call screen used to be the one place still hardcoded to Relay's own dark-navy look regardless of your app's theme; it now reads from the same colors/icons/typography as the rest. colors, icons, and typography are each independently optional — override one color without supplying the rest, and everything else keeps Relay's default (which itself already derives from your app's ambient MaterialTheme if you don't touch it at all).

Attachments & link previews

MessageComposer's attach button (Camera / Gallery / File) needs no permission at all for Gallery or File — both are out-of-process pickers. Camera is the one exception: add <uses-permission android:name="android.permission.CAMERA"/> to your own manifest if you want it; MessageComposer requests it at runtime the first time it's tapped. A message with an http(s):// link gets an automatic preview card — no setup needed, fetched server-side.

Security checklist before you ship

What you still own

Relay doesn't do: authentication (you already have this — it just needs a way to mint tokens for whoever is signed in), a user directory or search (Step 5 is how your existing one plugs in), push certificate/key management beyond what you configure once in the admin panel, or your app's icon — that comes from your own app module automatically, there's nothing to override there.

Where to go next