How to do Voice Call with Flutter, Firebase and Agora ?

Voice call is an important feature nowadays with the covid-19 pandemic. Voice call can be a really tricky feature to implement as a developer. The first question that can to my mind when I wanted to add that in my app was Where can I even start doing this ?. Well, with more research, I have implemented voice call in less than 2 hours and I will show you how.

Before Coding

Before we code, we need to create an agora account and get an app id. Go to https://agora.io, create an account after that, you will be in the agora dashboard.

In the agora dashboard, create a new app then copy the app id.

The design

Design is not the purpose of our article, I will show the design of the CallScreen and for the ReceiverCallScreen, that will be same thing, just one difference, the ReceiverCallScreen will have first two buttons, one for accepting the call, the other for refusing it. I will be focused on the logic behind.

We will use firebase in backend for real time data update for both communicated users. I won’t show you how to add a firebase in your project, there are a lot of articles describing that and it is not the purpose of this article.

Let’s start

We will have two screens, one for the caller and another for the call receiver. Let’s start with the caller screen.

import 'dart:async';import 'package:flutter/material.dart';
import 'package:agora_rtc_engine/rtc_engine.dart';
import 'package:uuid/uuid.dart';
class CallScreen extends StatefulWidget {
@override
_CallScreenState createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
@override
Widget build(BuildContext context){
return Scaffold(
// the page design
);
}
}

We create two importants variables in the class _CallScreenState, a RtcEngine and a Timer. those variables must not be final, with you have a linting problem with that, just add on top of the class, this comment // ignore: must_be_immutable. We also add a StreamSubscription which we will use to listen to changes to the call document.

class _CallScreenState extends State<CallScreen> {
RtcEngine engine;
Timer _timer;
var callListener;
...

Then we create state variables and some utilities functions.

Note: some of those functions can be used also for the ReceiverCallScreen.

/// for knowing if the current user joined
/// the call channel.
bool joined = false;
/// the remote user id.
String remoteUid;
/// if microphone is opened.
bool openMicrophone = true;
/// if the speaker is enabled.
bool enableSpeakerphone = true;
/// if call sound play effect is playing.
bool playEffect = true;
/// the call document reference.
DocumentReference callReference;
/// call time made.
int callTime = 0;
/// if the call was accepted
/// by the remove user.
bool callAccepted = false;
/// if callTime can be increment.
bool canIncrement = true;
void startTimer() {
const duration = Duration(seconds: 1);
_timer = Timer.periodic(duration, (Timer timer) {
if (mounted) {
if (canIncrement) {
setState((){
callTime += 1;
});
}
}
});
}
void switchMicrophone() {
engine?.enableLocalAudio(!openMicrophone)?.then((value) {
setState((){
openMicrophone = !openMicrophone;
});
})?.catchError((err) {
debugPrint("enableLocalAudio: $err");
});
}
void switchSpeakerphone() {
engine?.setEnableSpeakerphone(!enableSpeakerphone)?.then((value) {
setState((){
enableSpeakerphone = !enableSpeakerphone;
});
})?.catchError((err) {
debugPrint("enableSpeakerphone: $err");
});
}
Future<void> switchEffect() async {
if (playEffect) {
engine?.stopEffect(1)?.then((value) {
setState((){
playEffect = false;
});
})?.catchError((err) {
debugPrint("stopEffect $err");
});
} else {
engine
?.playEffect(
1,
await RtcEngineExtension.getAssetAbsolutePath(
"assets/sounds/house_phone_uk.mp3"),
-1,
1,
1,
100,
true,
)
?.then((value) {
setState((){
playEffect = true;
});
})?.catchError((err) {
debugPrint("playEffect $err");
});
}
}

Now, We need to make the caller join the channel, then prevent the call receiver that he has a call from the current user.

With the initRtcEngine function, we will create a random channelName with the uuid package. Then we create the RtcEngine, the engine is the most important thing in the code below. It handles all the call process. After that, we add eventHandler.

  • joinChannelSuccess: notify us if the current user has joined the channel. When that event happens we need to notify the call receiver that he has a call right now. We use a `createCall` function for that which will simply create a call document in the calls collection. And we execute switchEffect for playing a phone call sound. We alse create a StreamSubscription to the call document. If it is deleted (probably by the remote user), so we need to end the call.
  • leaveChannel: notify us if the current user leaved the channel.
  • userJoined: notify us if the remote user has joined the channel. When this event happens, we need to stop playing the phone call sound and start the timer.
  • userOffline: notify us if the remote user has leaved the channel.
Future<void> initRtcEngine() async {
final String channelName = Uuid().v4();
// Create RTC client instance
engine = await RtcEngine.create(agoraAppId);
// Define event handler
engine.setEventHandler(RtcEngineEventHandler(
joinChannelSuccess: (String channel, int uid, int elapsed) async {
debugPrint('joinChannelSuccess $channel $uid');
if (mounted) setState((){
joined = true;
});
callReference = await createCall(channelName);
switchEffect();
callListener = FirebaseFireStore.instance.collection("calls").doc(callReference.id).snapshots.listen((data){
if (!data.exists) {
// tell the user that the call was cancelled
Navigator.of(context).pop();
return;
}
});
},
leaveChannel: (stats) {
debugPrint("leaveChannel ${stats.toJson()}");
if (mounted) setState((){
joined = false;
});
},
userJoined: (int uid, int elapsed) {
debugPrint('userJoined $uid');
setState((){
remoteUid = uid;
});
switchEffect();
setState((){
if (!canIncrement) canIncrement = true;
callAccepted = true;
});
startTimer();
},
userOffline: (int uid, UserOfflineReason reason) {
debugPrint('userOffline $uid');
setState((){
remoteUid = null;
canIncrement = false;
});
switchEffect();
},
));
}

After we have put eventHandlers, we need to enable audio, set the channel profile and set the client role.

engine.enableAudio();
engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
engine.setClientRole(ClientRole.Broadcaster);

Last thing in our initRtcEngine function, we need to join the user to the channel. First, we must get a temporary security token for the channel, this is really important for the security of the calls and it is now required by Agora. For providing the token to the app, you need to create a server then an api for that, that simple. You can use the language you want, I’ve used JavaScript and in a few lines of code, my api was up and running. For the client side, you can use the http package but I’ve used dio. When we get the token then we can simply join the channel elsewhere if the client (the application) didn’t get the token, we need to go back. You also create a temporary token for a given channel name on agora dashboard for testing.

// temporary security call id
final String callToken = await getAgoraChannelToken(chatId, "publisher");
if (callToken == null){
// go back
Navigator.of(context).pop();
return;
}
// Join channel
await engine.joinChannel(callToken, channelName, null, 0);

For the server, We install the necessary packages in the new folder.

npm install restify agora-access-token

For the server, beside the app id, you also need an app certificate which will be in the agora dashboard. Go to the app and click on the edit icon. In the edit page, create a secondary certificate and set that as primary. Then copy the id.

Server Code.

const restify = require("restify");
const {RtcTokenBuilder, RtcRole} = require('agora-access-token');
const server = restify.createServer();// Middleware
server.use(restify.plugins.bodyParser());
server.post("/generate_access_token/", (req, res, next) => {
const { channel, role } = req.body;
if (!channel){
return res.send(400);
}
// get role
let callRole = RtcRole.SUBSCRIBER;
if ( role === "publisher"){
callRole = RtcRole.PUBLISHER;
}
let expireTime = 3600;
let uid = 0;
// calculate privilege expire time
const currentTime = Math.floor(Date.now() / 1000);
const privilegeExpireTime = currentTime + expireTime;
const token = RtcTokenBuilder.buildTokenWithUid(AGORA_APP_ID, AGORA_APP_CERTIFICATE, channel, uid, callRole, privilegeExpireTime);res.send({ token });
});
server.listen(process.env.PORT || 5500);

Client code

Future<String> getAgoraChannelToken(String channel,
[String role = "subscriber"]) async {
try {
final Dio dio = Dio();
final Response response = await dio.post(
"$adminUrl/generate_access_token/",
data: {"channel": channel, "role": role},
);
return response.data["token"] as String;
} catch (e) {
debugPrint("getAgoraChannelToken: $e");
}
return null;
}

Our initRtcEngine finished, we call that on the initState and don’t forget the dispose method.

@override
void initState(){
initPlatformState();
super.initState();
}
@override
void dispose(){
_timer?.cancel();
engine?.destroy();
callListener?.cancel();
// make sure the call was deleted
// and you use the callTime for the call logs.
deleteCall(callReference.id, callTime);
super.dispose();
}

Wow, Now, our `CallScreen` is completely finished. Ne need to create the `ReceiverCallScreen`.

After all these codes, the call system is not finished !!!

Yes, but good news, in ReceiverCallScreen, we will mainly use the same logic and code used in the CallScreen. A more effective call, will even join the two classes into one StatefullWidget but I wanted just the code to be simpler and comprehensive.

First, we need to show the ReceiverCallScreen screen to the user, whatever the route or the screen the user is in. I found out a way to do it with Stack Widget. We need to wrap the MaterialApp into a Stack then wrap the Stack into another MaterialApp. And we must show the ReceiveCallScreen when a call exists, for that we will need a callSubscription.

In main.dart

@override
void initState(){
super.initState();
FirebaseAuth.instance.authStateChanges().listen((user) {
final String uid = user.uid;
final stream = FirebaseFirestore.instance
.collection("calls")
.where("receiver", isEqualTo: uid)
.orderBy("time")
.snapshots();
callSubscription?.cancel();
callSubscription = stream.listen((value) {
calls = value.docs;
});
});
}
@override
void dispose(){
callSubscription?.cancel();
super.dispose();
}
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Stack(
children: [
// the actual app.
MaterialApp(
debugShowCheckedModeBanner: false,
home: Home(),
),
if (calls.isNotEmpty) ReceiveCallScreen(calls[0]),
],
),
);

All the utilities used for CallScreen will be the same for the ReceiverCallScreen. the main difference is in the initRtcEngine function. First, it is called when the user accepts the call, not in the initState, we call it here acceptCall. And if the user refuses the call, we just delete the call document. And also, we don’t need a callListener here. Like we are using a Stack Widget, if the call is deleted, the screen will be removed automatically.

Another, for ringing, in the initState, I use the flutter_ringtone_player package, for playing the user phone ringtone.

@override
void initState(){
super.initState();
FlutterRingtonePlayer.playRingtone();
}
Future<void> acceptCall() async {
FlutterRingtonePlayer.stop();
final String callToken = await getAgoraChannelToken(chatId);
if (callToken == null){
// nothing will be done
return;
}
// Create RTC client instance
engine = await RtcEngine.create(agoraAppId);
// Define event handler
engine.setEventHandler(RtcEngineEventHandler(
joinChannelSuccess: (String channel, int uid, int elapsed) async {
debugPrint('joinChannelSuccess $channel $uid');
joined = true;
startTimer();
},
leaveChannel: (stats) {
debugPrint("leaveChannel ${stats.toJson()}");
joined = false;
},
userJoined: (int uid, int elapsed) {
debugPrint('userJoined $uid');
remoteUid = uid;
if (playEffect) switchEffect();
if (!canIncrement) canIncrement = true;
},
userOffline: (int uid, UserOfflineReason reason) {
debugPrint('userOffline $uid');
remoteUid = null;
canIncrement = false;
switchEffect();
},
));
engine.enableAudio();
engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
engine.setClientRole(ClientRole.Broadcaster);
// Join channel
await engine.joinChannel(callToken, chatId, null, 0);
}

And that finally it, Our call system is up and running, you make that in your own app and if you have a problem, just contact me, I will enjoy helping you. If you notice problems in the article, notice me. You can also test Voice call with Flutter in our app Bongola Chat.

Thanks for reading, clamp and follow. See you soon.

Programmer from Africa. I’m here just to say a hello to the world.