Asif Alam · Lead Engineer

Calls that ring when the app is closed

How I built calling for Fleet: a real ring on a locked phone, one server function in charge of every call, and no phone number on either side.

Published
Reading time
5 min
By
Asif Alam · Lead Engineer

Fleet is Lebanon's car marketplace. A buyer finds a car, and the obvious next step is to call the dealer. The obvious way to make that happen is to print the dealer's number on the listing. I didn't want that, for two reasons.

The first is privacy, in both directions. A buyer asking about one car shouldn't have to hand a stranger their number for good, and a dealer shouldn't have to publish theirs to be reachable. The second is that a call that starts inside Fleet stays inside Fleet: it belongs to a conversation, and the admin dashboard can see that it happened.

So the goal was an in-app voice call. Not a chat with a microphone button: a call that rings. Full screen, answer and decline, on a phone that's locked in a pocket with Fleet nowhere near the foreground.

Ringing is the hard part

Getting two open apps to talk is the easy half. LiveKit carries the audio: every call gets a room, each side gets a token to join it, and neither phone ever dials a number.

The hard half is the phone that isn't listening. When Fleet is closed, nothing of it is running. No socket, no subscription, nothing to receive a message. The only way in is the operating system's own call machinery, and each platform has its own door.

On iPhone the door is a VoIP push, which the app hands to CallKit, so it gets the same incoming-call screen as an ordinary phone call. Apple is strict about that bargain: a VoIP push is for a call, and the app is expected to show one every time it gets one. On Android it's a data message through Firebase Cloud Messaging, which wakes the app just long enough to put up a full-screen call.

Under both sits expo-callkit-telecom, a native module built for exactly this. The rest of the work is making sure the push, the ring, the room and the database all agree.

One function owns the call

A call has a handful of states and two phones that each think they know which one it's in. The fastest way to get a phone that rings forever is to let both of them write it.

So neither of them does. One Supabase Edge Function, calls, is the only thing allowed to change a call's state. To start a call, the app asks it. The function writes the call row, mints a LiveKit token, and sends the push that wakes the other phone. Answering, declining and hanging up all go through the same function.

async function startCall(callerId: string, conversationId: string) {
  // who is on the other end is decided here, never sent by the app
  const calleeId = await resolveCallee(conversationId, callerId)
  if (!calleeId) return reply(403)

  const call = await insertCall({ callerId, calleeId, status: 'ringing' })
  const token = roomToken(call.room, callerId)
  await wake(calleeId, call) // VoIP push on iPhone, data message on Android
  return reply(200, { call, token })
}
A sketch of the shape, not Fleet’s code: every change to a call goes through one place

Both apps watch their call's row over Supabase Realtime, and that's the signalling: when the row changes, each side reacts. The app still has a state machine of its own for the screen (with its own tests), but it follows the row. It never argues with it.

The push code lives in one shared module. Apple and Google each want a signed token before they'll take a push (ES256 for one, RS256 for the other), and signing a fresh one for every call is wasted work, so the function keeps them for 50 minutes, just inside the hour they're good for.

Numbers that never leave the server

Privacy isn't a screen you hide the number behind. If the app knows a number, so does anyone curious enough to read the app's network traffic. So the app never asks for one.

When you tap call, the app sends the conversation you're in: not a person, and certainly not a phone number. The server works out who is on the other side from the conversation itself. The callee is a decision the server makes, not a parameter the client sends, which also means there's no field a modified app could fill in with somebody else.

select user_id
from conversation_participants
where conversation_id = $1
  and user_id <> $2;  -- the caller
Simplified: the callee is whoever else is in the conversation

Making the ringing stop

Here's a bug you only get with native calls. A buyer calls, thinks better of it and hangs up after two rings. Their side is done. But the dealer's phone is ringing through the operating system, not through Fleet, and nobody told the operating system.

Nothing of Fleet is running on that phone to tell it, either. The calling library could start a ring from a push, but it had no way to stop one. So I patched it: a callEnded push that the native side handles by ending the ringing call. When the caller hangs up, the server sends it, and the other phone goes quiet.

Patching a dependency is usually a smell. Here the alternative was a phone ringing for a call that no longer existed, which is worse than a smell. The patch is a small file in the repo, tied to the library's exact version, so an upgrade has to face it on purpose.

It wasn't the only fix of its kind. The history around calls reads like a list of the ways a phone call can go wrong: answering on iPhone before the app had joined the room, no audio when a call woke an app that had been killed, rings that outlived their calls. Every one of them only shows up on a real phone, in a real state.

When a phone just disappears

Either phone can vanish mid-call. An app crashes. A battery dies. Someone drives into a tunnel. Nobody sends a polite "end call" from a phone that's switched off.

LiveKit notices, though: it sees people leave the room. Its webhook lands on a second function that closes the room behind them, so a call doesn't stay live forever because one side never said goodbye. Behind that there's a sweep, the four-hour safety net for anything the webhook missed.

Stale state in a call system doesn't sit quietly in a table. It rings.

What I'd tell myself at the start

Pick one writer. Every call bug I could think of started with two things that both believed they were in charge of the same call.

Let the server decide who. Anything the client sends is a suggestion, and a phone number is a suggestion you can't take back.

Design for the phone that's gone. Crashes, dead batteries and killed apps aren't the edge of the problem; for calls, they're the middle of it.

Own the native layer. If a library stands between you and the right behaviour, a small patch pinned to its version beats a workaround in JavaScript that pretends the problem is somewhere else.

Test the logic away from the phone. The call function's logic and the app's state machine both have tests that run without a device, which leaves the device for the things only a device can tell you.

None of this is exotic. It's a push, a room, a row and a function. The work is in getting all four to agree on one question, all the time: should a phone be ringing right now?

Sources

  1. Apple: CallKit (opens in a new tab)
  2. Apple: Responding to VoIP notifications from PushKit (opens in a new tab)
  3. Android: Display time-sensitive notifications (full-screen intents) (opens in a new tab)
  4. Firebase Cloud Messaging: Receive messages on Android (opens in a new tab)
  5. LiveKit: Access tokens and grants (opens in a new tab)