case study · ambitio

voice calls that
connect anyway.

shipping in-app voice calls end to end with agora, built to connect reliably even on patchy connections, for a production app with 2,000+ active users.

role

mobile engineer

where

ambitio

stack

flutter, agora, pushkit/callkit, fcm

live on

play ↗   app store ↗

overview

i built in-app voice calls at ambitio end to end. the audio runs on agora, the signaling on a socket, and the part that wakes a locked phone on the operating systems themselves. but the spine of it is a single dart state notifier that owns one immutable call state. every screen and every native event reads from it; the native layers only ever report what happened and draw the ringing screen, they never own the truth of where a call is.

here is the honest framing, and it shaped every decision: the happy path of a voice call is easy. two people with good signal tap a button and talk. everything that actually mattered lived at the edges, dropped networks, locked phones, and two operating systems that flatly refuse to agree on how an incoming call should work.

the problem

students and counselors needed to talk inside the app, on demand, on whatever connection they happened to have. that means lifts, tunnels, trains, a phone quietly handing off from wifi to cellular as someone walks out the door.

a call that dies on the first network blip is worse than no call feature at all, because people stop trusting it. once a user has had two calls cut out on them, they go back to a normal phone dialer and never tap your button again. so "connect" was never the real goal. "stay connected, and when you genuinely can't, say why out loud" was.

constraints

transportall real-time audio and quality signals run on agora, wrapped by our own calling service.
networkconnections that switch (wifi to cellular) and drop mid-call, then often recover a few seconds later.
ringingthe phone has to ring when locked or backgrounded, and ios and android demand entirely different native paths to do it.
lifecyclecall state must survive background, kill, and resume, and never get stuck as a ghost call.

what i built

the calling service is the spine of it. it owns the full lifecycle as an explicit state machine, idle · initiating · ringing · connecting · connected · reconnecting · ended, and holds the single source of truth for which of those a call is in. one isActive check (ringing, connecting, connected, or reconnecting) is all it takes to reject a second incoming call, or to know whether a teardown is even needed. every screen and native event reads this one state instead of inferring where the call is from a half-finished side effect.

agora is the audio. both sides join the same channel as broadcasters, mic published, remote audio auto-subscribed, and the engine's callbacks, who joined, who dropped, how good the link is, the token about to expire, feed straight into that state machine.

on top of that sit the pieces that turned a fragile call into a call people actually trusted: a reconnect grace window, a live quality indicator, two completely separate native ring paths, and a way to answer or decline a call even when the app and its socket are both dead. each earned its own story below.

the 10-second
grace window

this is the change that did the most for reliability, and it is almost embarrassingly simple once you see it: when the network drops mid-call, do not end the call. hold a window of roughly ten seconds and keep trying to reconnect underneath the user. most drops are not really drops, they are blips. someone steps into a lift, rides through a tunnel, walks from office wifi onto cellular. the signal vanishes for two, four, maybe seven seconds, and then it comes right back on its own.

so a network loss is treated as probably temporary, and there are actually two timers doing it. if agora reports the link itself dropped (onConnectionLost), the call goes to reconnecting and a ten second timer starts; if agora rejoins the channel first the timer is cancelled and the call snaps back to connected, and only if the full window elapses does it tear down. the second timer watches the other person. when the remote user goes offline i read agora's reason, and this is the subtle part: userOfflineQuit means they deliberately hung up, so i end immediately, while any other reason means their phone just blipped, so i hold the same ten second window for them to rejoin.

(there's a third, separate fifteen second timer that is easy to confuse with this one: a watchdog on the initial agora join, so a call that never manages to connect in the first place gives up cleanly instead of ringing into the void.)

the human reasoning underneath is the whole point: a call that drops the instant the signal dips is a call people stop trusting. ten seconds of patience is the entire difference between "the network hiccuped and we carried on" and "this app's calls keep cutting out."

explaining
a rough call

the grace window keeps a shaky call alive, but a shaky call still sounds bad, and staying silent about it is its own failure. when audio gets choppy and the app says nothing, the user assumes the app is broken, not their signal.

so i surfaced a live quality indicator straight from agora's onNetworkQuality, which reports your uplink and downlink separately. i take the worse of the two and collapse it into a handful of levels, and while the call is connected the user sees a plain "poor connection" when it degrades, or "no network" when it falls away, sitting right where the call timer normally ticks. it only redraws when the level actually changes, so it never flickers. it does not fix the network, but it reframes the moment: a rough call becomes explained instead of mysterious. people forgive a call that admits their signal is weak. they do not forgive a call that just garbles and stays quiet about why.

two phones,
two ringers

here is where one feature quietly became two. ios and android fundamentally disagree about how a phone should ring, and there is no honest shared abstraction that papers over the gap.

on ios, an incoming call rides a true voip push through PushKit, and the rule is unforgiving: the moment that push arrives you must hand the call to CallKit synchronously, in the same call stack, or the os kills your app for lying about a voip wake. do that, and the system itself draws the native incoming-call screen, the real one, with accept and decline, and wakes your app for you. when the user answers, CallKit hands me back the whole call payload, so the call can bootstrap even from a cold launch where no flutter code had run yet.

on android, there is no such handoff and no CallKit equivalent. the call arrives as a deliberately data-only high-priority fcm message (include any notification block and android simply won't wake a killed app), and from there you are on your own. i draw the ringing experience myself: a full-screen-intent activity that turns the screen on and shows over the lock screen, with its own ringtone, vibration, and a thirty second auto-timeout. declining works without ever unlocking the phone; answering asks the keyguard to step aside and then carries you in. same product, same button, two completely different machines underneath, and the calling service has to present one coherent call on top of both.

the silence
before audio

one ios detail took longer to get right than i'd like to admit. agora cannot send a single byte of audio until CallKit has actually activated the audio session, and CallKit does that on its own schedule, a beat after the user answers. call joinChannel a moment too early and the call connects to silence.

so the join is deferred. the token, channel and uid are parked, and the instant CallKit signals audio_session_activated, the parked join is replayed against the real session. the user just hears the other person come through; they never know the audio engine was politely waiting its turn for the os to hand over the microphone.

the fight to
make it ring

android's divergence had a sting in the tail. to light up the lock screen at the exact moment that matters, you need the USE_FULL_SCREEN_INTENT permission, and on android 14 and up google play scrutinizes it hard. it is not a checkbox you tick and ship. you write a declaration, you justify why a calling app genuinely needs to commandeer the whole screen from a locked state, you submit it, and then you wait for review and push back when it bounces. on the device itself the app also has to check canUseFullScreenIntent() at runtime and walk the user back to settings if it was ever revoked.

and even with the permission granted, android still isn't done with you. xiaomi, oppo, and vivo skins each block background activity launches behind their own "autostart" and "show on lock screen" toggles, so on those phones a full-screen call would quietly collapse into a notification the user might never glance at. so there are deep-links straight into each of those oem settings pages, plus a battery-optimization exemption prompt, because a call you can't see is the same as a call that never came.

and it was the entire ballgame. that whole fight is the difference between a call that wakes a locked phone and a call the user simply never sees. all the reconnect logic in the world is worthless if the phone never rings in the first place.

answering when
nothing is awake

the hardest state is the one where almost nothing is running: the app is killed, the socket is gone, and a call is ringing through the native ui. answering or declining still has to reach the backend. so at launch the app hands the chat base url down to the native side and stashes it; the auth token is already sitting in platform storage. when the user declines from that native screen, the native code reads the url and token itself and posts straight to the backend, no flutter engine, no socket required.

the events the native side generates while flutter is still booting (incoming, accepted, declined) are queued in a small native store and drained the instant the dart side announces it's ready, so nothing fired during the cold-launch race is ever lost. and because a socket woken from the background can take a moment to come up, an accept is retried until it lands, and on every reconnect the app asks the backend "is this call still alive?", so it never keeps ringing a call the caller already gave up on.

no ghost
calls

a call feature lives or dies on never getting stuck. an unanswered call times out on its own after thirty seconds. teardown is idempotent: it refuses to run twice, cancels all of its timers, leaves the agora channel, dismisses the native ui, and only then resets to idle, and even that reset checks it isn't stomping a newer call that has already started.

"busy" is only ever sent when a call is genuinely connected, never mid-handshake and never mid-reconnect, so a brief drop can't make you falsely reject the next legitimate caller. and while any call is active, the app defers its normal post-login routing, so an auth refresh firing at the wrong moment can't quietly pop the call screen out from under two people who are still talking.

making it
debuggable

call bugs are miserable to reproduce. they need a real device, a real bad network, and a locked screen, and they almost never happen at your desk. so every fragile seam, the agora callbacks, the reconnect timers, the native wakeups, the cold-launch accept, is wrapped in error capture that tags each failure with exactly where it came from (CallNotifier.acceptCall, AgoraService.joinChannel, and so on) before it goes to sentry.

and the lifecycle itself is measured, not guessed at. every call emits analytics for how long it rang before it was answered, how long the caller waited, how long the conversation lasted, whether mute or speaker were ever touched. so when a call did something strange in the field, i could read the shape of what happened after the fact instead of trying to recreate a tunnel on demand, and fix the edge before it turned into a wave of "calls don't work" tickets.

outcome

2,000+

active users

2

native ring paths: voip/callkit · fcm

~10s

reconnect grace window

calls ring on locked phones across both platforms, survive the short network drops that used to kill them, tell users when the connection is rough instead of going mysteriously quiet, and can be answered or declined even when the app and its socket are both asleep. running in production for 2,000+ active users. the lesson i keep: the feature was never the call. it was the ten seconds you wait before giving up, and the fight to make the phone ring at all.