Adding calls to an app usually means learning WebRTC, running a signaling server, and setting up TURN relays so calls still connect on mobile data. That's weeks of work before you hear a single "hello".
In this tutorial we'll skip all of that and build a working 1:1 voice call between two Android phones in Flutter, with:
- call, accept and reject
- mute and hang up
- a "Reconnecting…" state when someone's network drops for a few seconds
We'll use RTCExpress, a hosted voice/video/chat SDK I'm building at Devsquare Labs. Signaling, TURN and media servers are hosted for you, and the Free plan covers everything in this post.
1. What you need
- Flutter 3.19 or newer (
flutter --version) - Two Android devices, or one phone and one emulator (Android 7.0 / API 24+)
- A free RTCExpress project for the App ID: create one here (no credit card)
The Flutter plugin supports Android today. iOS is included but still experimental.
2. Create the app and add the plugin
flutter create voice_call_demo cd voice_call_demo flutter pub add rtcexpress permission_handler permission_handler asks the user for microphone access at runtime.
3. Set the Android minimum SDK
In android/app/build.gradle.kts (or build.gradle in older projects):
defaultConfig { minSdk = 24 // ...keep the other generated values } You don't need to touch AndroidManifest.xml. The plugin already declares the internet and microphone permissions.
4. Write the call screen
Replace lib/main.dart with this. It's the whole app: one screen that connects, joins a room, and places or answers a call.
import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:rtcexpress/rtcexpress.dart'; // Test values passed with --dart-define (see step 6). // In a real app, fetch the token from your own backend. Never ship an App Secret. const appId = String.fromEnvironment('RTC_APP_ID'); const userId = String.fromEnvironment('RTC_USER_ID'); const peerId = String.fromEnvironment('RTC_PEER_ID'); const token = String.fromEnvironment('RTC_TOKEN'); const roomId = 'voice-test'; void main() => runApp(const MaterialApp(home: CallScreen())); class CallScreen extends StatefulWidget { const CallScreen({super.key}); @override State<CallScreen> createState() => _CallScreenState(); } class _CallScreenState extends State<CallScreen> { final rtc = RTCExpress(); String status = 'Not connected'; bool joined = false; bool incoming = false; bool inCall = false; bool muted = false; @override void initState() { super.initState(); rtc.on('roomJoined', (_) => setState(() { joined = true; status = 'Joined $roomId'; })); rtc.on('callInvite', (_) => setState(() => incoming = true)); rtc.on('callState', (p) => setState(() { final state = p['state']; status = state == 'reconnecting' ? 'Reconnecting…' : 'Call $state'; inCall = ['ringing', 'connecting', 'connected', 'reconnecting'].contains(state); if (state != 'ringing') incoming = false; if (!inCall) muted = false; })); // The other person lost their network for a moment. rtc.on('peerReconnecting', (p) => setState(() { status = p['reconnecting'] == true ? '${p['userId']} is reconnecting…' : 'Call connected'; })); rtc.on('error', (p) => setState(() => status = 'Error: ${p['message']}')); } Future<void> connect() async { if (!await Permission.microphone.request().isGranted) { setState(() => status = 'Allow microphone access to make calls'); return; } setState(() => status = 'Connecting...'); await rtc.init(const RTCInitOptions( serverUrl: 'https://api.devsquarelabs.com', appId: appId, userId: userId, token: token, mediaMode: 'p2p', // 1:1 calls go directly between the two phones )); await rtc.joinRoom(roomId); } @override void dispose() { rtc.destroy(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Voice call: $userId')), body: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(status, textAlign: TextAlign.center), const SizedBox(height: 24), if (!joined) FilledButton(onPressed: connect, child: const Text('Connect and join')), if (joined && !inCall) FilledButton(onPressed: () => rtc.callUser(peerId), child: Text('Call $peerId')), if (incoming) ...[ FilledButton(onPressed: rtc.acceptCall, child: const Text('Accept')), OutlinedButton(onPressed: rtc.rejectCall, child: const Text('Reject')), ], if (inCall && !incoming) ...[ OutlinedButton( onPressed: () { setState(() => muted = !muted); rtc.muteMicrophone(muted); }, child: Text(muted ? 'Unmute' : 'Mute'), ), FilledButton(onPressed: rtc.endCall, child: const Text('End call')), ], ], ), ), ); } } How it works
-
initconnects with a short-lived user token.mediaMode: 'p2p'sends audio straight between the two phones. -
joinRoomasks to join a room. TheroomJoinedevent confirms it, so the Call button only appears after that. -
callUserrings the other user. On their phonecallInvitefires, and they canacceptCallorrejectCall. -
callStatereportsringing,connecting,connected,reconnecting,rejectedandended, which drive the buttons.
5. Calls that survive a network drop
Real users walk into lifts, switch from Wi-Fi to mobile data, or lose signal in a tunnel. With plugin version 0.1.6 or later, a call doesn't die when that happens:
- The phone that lost its network gets
callState=reconnecting, thenconnectedagain when it's back. - The other phone gets
peerReconnecting, so you can show "Alice is reconnecting…" instead of a dead screen. - If the network isn't back within 30 seconds, the call ends on both sides with the error code
connection_lost.
There's nothing to configure. The SDK reconnects on its own, and the two handlers in the code above just show the state to the user.
Try it: during a call, turn on airplane mode on one phone for 5 seconds, then turn it off.
6. Create two test tokens and run it
In the RTCExpress console, open Token tools and create two short-lived tokens for the same room:
- user
alice, roomvoice-test - user
bob, roomvoice-test
Find your device IDs with flutter devices, then run Alice on the first device:
flutter run -d FIRST_DEVICE_ID \ --dart-define=RTC_APP_ID=YOUR_APP_ID \ --dart-define=RTC_USER_ID=alice \ --dart-define=RTC_PEER_ID=bob \ --dart-define=RTC_TOKEN=ALICE_TOKEN And Bob on the second, in another terminal:
flutter run -d SECOND_DEVICE_ID \ --dart-define=RTC_APP_ID=YOUR_APP_ID \ --dart-define=RTC_USER_ID=bob \ --dart-define=RTC_PEER_ID=alice \ --dart-define=RTC_TOKEN=BOB_TOKEN - Tap Connect and join on both and allow the microphone.
- On Alice's phone, tap Call bob.
- On Bob's phone, tap Accept. Both screens show Call connected.
Two phones next to each other will echo, so use headphones or move apart.
Troubleshooting
- minSdk build error: set
minSdk = 24(step 3), thenflutter clean. - Error right after Connect: check the App ID, that each phone uses its own user ID, and that the token hasn't expired.
- Call button never appears: both tokens must be for the same room.
- No incoming call:
RTC_PEER_IDmust exactly match the other phone's user ID. - Connected but silent: check the microphone permission in Android settings and the media volume.
Before you ship
- Tokens come from your backend, not
--dart-define. Your server signs the user in, then requests a short-lived token fromPOST https://api.devsquarelabs.com/v1/token. The App Secret never goes into the APK. - Ringing a closed app needs push notifications through your own Firebase project.
- Test on real networks: mobile data, Wi-Fi ↔ data switches, denied permissions, rejected calls and expired tokens.
The full version of this guide, with a video calling follow-up, is on the RTCExpress site.
If you try it, I'd love to hear how it goes, especially what breaks. What kind of app are you adding calls to? 👇