overview
i built voice calls in the ambitio app, so students and counselors can call each other without leaving it. the audio runs on agora, the signaling goes over a socket, and the ringing on a locked phone is done natively on ios and android.
a call between two people with good signal was the easy part. almost all the work was calls in lifts and trains, locked phones, and ios and android wanting completely different things before a phone will ring. if a call drops on the first bad patch of network, people stop trusting it and go back to their normal dialer.
constraints
how it fits together
one dart notifier holds the call as a state machine: idle · initiating · ringing · connecting · connected · reconnecting · ended. every screen reads that one state, and agora's events (who joined, who dropped, connection quality, the token running out) go straight into it. the native code on each platform only reports what happened and draws the ringing screen. it never decides what state the call is in.
the 10-second grace window
this helped more than anything else, and it's a small change: when the network drops mid-call, don't end the call. wait about ten seconds and keep trying to reconnect. most drops fix themselves. someone gets into a lift or walks from office wifi onto data, and the signal is gone for a few seconds before it comes back.
there are two timers. if my own connection drops (onConnectionLost), the call goes to reconnecting and a ten second timer starts. if agora gets back in first, the timer is cancelled and the call carries on. the second timer is for the other person. when they go offline, agora tells me why: userOfflineQuit means they hung up, so i end the call straight away, and anything else means their network blipped, so they get the same ten seconds to come back.
ten seconds came from timing how long a lift ride or a short tunnel actually takes. it covers most of them without leaving someone staring at a frozen screen.
saying when a call is rough
a call that survives a drop can still sound bad, and if the app says nothing, people assume the app is broken. agora's onNetworkQuality reports upload and download separately. i take the worse of the two, turn it into a few levels, and show "poor connection" or "no network" where the call timer usually is. it only redraws when the level changes, so it doesn't flicker.
it doesn't fix the call, but we stopped getting "the audio is broken" reports that were really one bar of signal in a basement.
two phones, two ringers
on ios, an incoming call comes in as a voip push through PushKit. the rule is strict: you have to hand it to CallKit right away, in the same call stack, or ios kills your app. do that, and ios draws its own incoming call screen and wakes the app. when you answer, CallKit hands back the call details, so the call can start even before flutter has loaded.
on android, there's nothing like CallKit. the call comes in as a data-only fcm message (with a notification block in it, android won't wake a killed app), and i draw the ringing screen myself: a full-screen activity that turns the screen on and shows over the lock screen, with its own ringtone, vibration and a thirty second timeout. the calling service then has to make both of these look like one call.
the silence before audio
one ios bug took longer than i'd like to admit. agora can't send any audio until CallKit turns on the audio session, and CallKit does that a moment after the user answers. join the channel too early and the call connects to silence.
so i hold on to the token, channel and uid, and only join once CallKit says audio_session_activated. the other person comes through a fraction of a second later, with audio this time.
getting play to let it ring
on android 14 and up, showing a call over the lock screen needs the USE_FULL_SCREEN_INTENT permission, and google play reviews it closely. i had to write a declaration explaining why a calling app needs it, submit it, and argue back when it bounced. the app also checks canUseFullScreenIntent() at runtime and sends the user to settings if it's been turned off.
then xiaomi, oppo and vivo each have their own "autostart" and "show on lock screen" settings that block this, so on those phones a call would quietly turn into a notification. the app links straight to each of those settings pages and asks to be left out of battery optimisation.
none of this was interesting, and it took longer than the calling logic. but the reconnect handling doesn't matter on a phone that never rang.
answering when the app is asleep
the hardest case is when the app has been killed, the socket is gone, and a call is ringing on the native screen. answering or declining still has to reach the server. so when the app starts, it passes the server url down to the native side, which already has the auth token. if you decline from the native screen, the native code tells the server itself, without flutter or the socket.
anything that happens natively while flutter is still starting gets queued and handed over once it's ready. and after every reconnect the app asks the server whether the call is still alive, so it never keeps ringing for someone who already hung up.
no stuck calls
the thing i worried about most was a call that gets stuck. an unanswered call ends itself after thirty seconds, and ending a call can only run once: it cancels its timers, leaves the channel, closes the native screen, and checks it isn't wiping out a newer call before it resets. "busy" only goes out when a call is actually connected, so a quick drop can't make you reject the next person who calls.
making it debuggable
call bugs almost never happen at your desk. every fragile spot reports errors to sentry tagged with where they came from (like CallNotifier.acceptCall), and every call logs how long it rang, how long it lasted and whether mute or speaker were used. when a call did something odd out there, i could read what happened instead of trying to recreate a tunnel.
outcome
2,000+
active users
2
native ring paths: voip/callkit · fcm
~10s
reconnect grace window
calls ring on locked phones on both platforms, get through the short drops that used to end them, tell you when the connection is bad, and can be answered or declined while the app is asleep. it's in production for 2,000+ active users.
what i'd change: the fixes for xiaomi, oppo and vivo are hardcoded, with one branch and one settings link each. it works on the phones people actually have, but every new android skin means another branch someone has to remember to add.