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

  • init connects with a short-lived user token. mediaMode: 'p2p' sends audio straight between the two phones.
  • joinRoom asks to join a room. The roomJoined event confirms it, so the Call button only appears after that.
  • callUser rings the other user. On their phone callInvite fires, and they can acceptCall or rejectCall.
  • callState reports ringing, connecting, connected, reconnecting, rejected and ended, 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, then connected again 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, room voice-test
  • user bob, room voice-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 
  1. Tap Connect and join on both and allow the microphone.
  2. On Alice's phone, tap Call bob.
  3. 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), then flutter 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_ID must 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 from POST 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? 👇