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. underneath all of it is a single dart state notifier holding one immutable call state. every screen and every native event reads from that. the native layers only ever report what happened and draw the ringing screen; they never decide where a call is.

the happy path of a voice call is easy: two people with good signal tap a button and talk. almost all of the work was in the other cases, on dropped networks and locked phones and two operating systems that 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 most of what follows is about staying connected through a bad thirty seconds, and about saying something useful when it genuinely can't.

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 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 carries the audio. both sides join the same channel as broadcasters, with the mic published and the remote audio subscribed automatically. agora reports back on who joined, who dropped, how good the link is, and when the token is about to expire, and all of those callbacks feed straight into the state machine.

on top of that sit the four things that took most of the time: a reconnect grace window, a live quality indicator, two completely separate native ring paths, and a way to answer or decline a call when the app and its socket are both dead. the rest of this is about those.

the 10-second
grace window

this did more for reliability than anything else and it is a small change: when the network drops mid-call, don't end the call. hold a window of roughly ten seconds and keep trying to reconnect underneath the user. most drops recover on their own. someone steps into a lift, rides through a tunnel, or walks from office wifi onto cellular, and the signal is gone for two, four, maybe seven seconds before it comes back.

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

ten seconds isn't a principled number. it came from timing how long a lift ride or a short tunnel actually takes, and it's long enough to cover most of them without leaving someone staring at a frozen screen wondering whether the call is over.

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 doesn't fix anything. it just means a rough call has a visible reason, and we stopped getting reports that the audio was broken when the actual problem was one bar of signal in a basement.

two phones,
two ringers

this is really two features. ios and android disagree about how a phone should ring at a level where there's no shared abstraction worth writing.

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. so the two platforms share a button and almost nothing else, 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, a fraction of a second later than they otherwise would.

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.

none of this is interesting work and it took a lot longer than the calling logic did. it's also what everything else depends on, since the reconnect handling doesn't matter on a phone that never rang.

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

the failure i was most worried about was a call that gets 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.

the lifecycle is measured too. 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 what happened afterwards, instead of trying to recreate a tunnel on demand.

outcome

2,000+

active users

2

native ring paths: voip/callkit · fcm

~10s

reconnect grace window

calls ring on locked phones on both platforms, survive the short drops that used to kill them, show the user when the connection is rough rather than going quiet, and can be answered or declined while the app and its socket are both asleep. it's running in production for 2,000+ active users.

the part i'd change: the oem workarounds are hardcoded per manufacturer, so xiaomi, oppo and vivo each get their own branch and their own settings deep-link. it works on the phones people actually have, and every new skin is another branch someone has to remember to add.