Saltar al contenido

Crear aplicación de Llamadas de Voz con Flutter, Firebase y Agora

abril 15, 2021

Si estás pensando en crear una aplicación de llamadas por Internet con Flutter, has llegado al lugar indicado. Usando Firebase y Agora podemos crear una aplicación que nos permita realizar llamadas de voz entre usuarios, aquí aprenderás cómo hacerlo.

Hoy en día, con la pandemia, puede ser una gran idea implementar la posibilidad de realizar llamadas de voz en nuestras aplicaciones, ya sabes, como las llamadas que podemos realizar a nuestros contactos en Whatsapp o Messenger. Imagina tener una aplicación tipo UBER y poder realizar una llamada de voz con el Conductor antes o después que llegué a recogerte, sin necesidad de compartir tu número de teléfono.

Cuando te planteas una tarea como esta, viene una duda a tu mente ¿Por donde empiezo?. Muy bien, después de buscar en Internet la documentación necesaria durante algunas horas, encontré una forma de realizarlo y en este artículo te mostraré como hacerlo.

Antes de programar

Antes de empezar a programar nuestra aplicación tenemos que conseguir un Application Id del servicio en Internet https://agora.io, crea una cuenta, es completamente gratis, no enseñaré cómo hacer esto ya que es de lo más común del mundo de Internet. Una vez que tengas una cuenta podremos entrar al dashboard de Agora.

Agora dashboard

Ya en el dashboard de Agora, debes crear una nueva app y copiar el App Id que la aplicación te generará.

El diseño

El diseño no es el objetivo de este artículo, así que lo veremos solo superficialmente, te mostraré el diseño de la pantalla para recibir y hacer llamadas. Siempre, antes de empezar a programar te recomiendo hacer el diseño del resultado en cualquier aplicación de diseño que te guste o manejes. Es solo para asegurarte de que no estás dejando algo de lado que es necesario.

Pantalla de llamadas

Para manejar la actualización de datos en tiempo real usaremos Firebase, sin embargo, enseñar Firebase tampoco es el objetivo de este artículo así que te dejaré enlaces para que vea paso a paso como instalar Firebase en tu proyecto de Flutter.

Instalar Firebase en Flutter (Android)

Si después de ver los vídeos, tienes alguna duda sobre este asunto de instalar Firebase a tu proyecto, puedes dejarlo en los comentarios y con gusto te ayudaré en lo que pueda.

Muy bien, comencemos

En nuestra aplicaciones tendremos principalmente 2 pantallas, una para las llamadas entrantes y otra para las llamadas recibidas. Comencemos con el código para la pantalla para realizar la llamada.

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
    );
  }
}

Muy bien, ahora tenemos que crear 2 variables que son importantes en nuestra aplicación, una será un RtcEngine y un Timer, estos valores no deben ser final, tenerlo en cuenta. Si tienes algún problema con esto y el Widget te dice que los valores deben ser final, solo debes poner // ignore: must_be_immutable en la parte superior del widget.

También agregaremos un StreamSubscription, con el que vamos a escuchar los cambios que se realice en la información de nuestros documentos. Veamos la declaración de estas varialebles.

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

Ahora crearemos variables de estado y otra funciones útiles

Nota: Muchas de estas funciones también pueden ser usadas para recibir llamadas.

/// 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");
    });
    }
}

Muy bien, ahora tenemos que agregar el usuario que está llamando al canal, esto para evitar que el usuario que esta llamando reciba una llamada durante está en otra.

Con la función initRtcEngine, vamos a crear un nombre de canal aleatorio con el Paquete uuid. Entonces creamos el RtcEngine, esto es lo más importante en el código que escribiremos a continuación. Este objeto será el que controle todo lo relacionado a la llamada, aunque luego crearemos algunos otros controladores. Veamos algunos:

  • joinChannelSuccess: Notificará si el usuario actual se ha agregado a un canal. Cuando este evento suceda, tendremos que notificar al usuario que recibirá la llamada, que tiene una llamada entrante. Para hacer esto creamos una función «createCall» que se encargará que agregar un documento a una colección en Firebase. Entonces usaremos el switchEffect para hacer que el smartphone del usuario receptor reproduzca un sonido. También usamos el StreamSubscription para mantener la conexión, si el StreamSubscription es detenido, debemos detener la llamada.
  • leaveChannel: Nos notificará si el usuario actual abandona el canal.
  • userJoined: Nos notificará si el usuario receptor recibe la llamada, si esto sucede tendremos que detener el sonido y comenzar el Timer.
  • userOffline: Nos notificará si el usuario receptor ha abandonado el canal, quizá por temas conexión.
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();
    },
    ));
}

Después de definir los controladores, necesitamos habilitar el audio, establecer el perfil del canal y el rol del cliente.

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

Lo último que haremos en nuestro initRtcEngine es agregar al usuario al canal. Primero, tenemos que obtener un token de seguridad temporal para el canal, esto es realmente importante para la seguridad de las llamadas y ahora es obligatorio para el uso de Agora. Para proveer el token a la aplicación, necesitamos crear un servidor que nos proporcione una API para eso, así de simple.

Puedes usar el lenguaje de programación que prefieras, yo utilice Javascript y con unas pocas líneas de código ya tenía un servidor corriendo con nuestra API en línea. A estas alturas seguro ya tienes una API corriendo, puedes usarla sin problema, no es necesario hacer una nueva.

En el lado del cliente, puedes utilizar el paquete http para realizar la peticiones, sin embargo yo use dio. Una vez que tengas el token, tienes que agregar el cliente al canal. Si por alguna razón, no obtienes el token debes volver a la generación. También puedes crear un token temporal para un canal definido en Agora, para realizar pruebas.

// 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);

En nuestro servidor en Node debemos instalar el paquete de Agora. Este es necesario para generar estos tokens.

npm install restify agora-access-token

En el servidor, es decir del lado de la API, necesitas un certificado de aplicación, esto lo consigues en el dashboard de Agora. Para hacerlo, debes ir a la opción de editar aplicación en Agora, en la página de edición crea un nuevo certificado y selecciónalo como el predeterminado. Ahora copia el Id.

Código del servidor

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

Código del lado del cliente

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;
}

Ahora nuestro initRtcEngine está terminado, ahora solo debemos llamarlo en el initState del Widget. No olvides eliminarlo en el dispose del Widget también.

@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();
}

Excelente, con esto ya tenemos terminada la pantalla del usuario que realiza las llamadas. Ahora solo tenemos que hacer la pantalla del que recibirá la llamada. Después de todo, el usuario que recibe es parte del paquete, sin receptor, no hay llamada.

La buena noticia, es que el ReceiverCallScreen usa en su mayoría la misma lógica que el CallerScreen, así que será un poco más sencillo de llevar a cabo. Una forma más afectiva de realzar este código sería realizar ambas pantallas llamando cada widget desde un mismo código. Sin embargo para facilitar el entendimiento y la comprensión, he decidido hacerlo en Widgets separados.

Primero, necesitamos mostrar la pantalla de llamada entrante al usuario, aunque el usuario no esté en esa ruta. Encontré la forma de hacerlo, usando un simple Stack. Ya podrás imaginar cómo lo resolví. Necesitamos mostrar el Widget de llamada cuando exista una llamada entrante, así que necesitamos un callSubscription.

@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]),
        ],
    ),
);

Todas las funcionalidades que usamos en el CallerScreen van a ser validas en esta nueva pantalla. La principal diferencia está en la función initRtcEngine. Primero, es la funcionalidad de aceptar la llamada, que obviamente no puede ser llamado en el initState, tenemos que crear una función llamada acceptCall.

Si el usuario rechaza la llamada, debemos borrar el documento en la colección calls en Firebase. Algo más que tenemos que tener en cuenta es que en este último caso, no necesitamos el callListener, ya que nunca se entrará al canal, al cancelar la llamada usaremos el Stack para volver a la pantalla anterior.

Algo más, para hacer que el celular del usuario receptor suene, podemos usar el paquete flutter_ringtone_player. Veamos el código para entender mejor lo explicado:

@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);
}

Listo. Ya con esto deberíamos tener nuestro sistema de llamadas completo. Prueba en tus dispositivos, recomiendo hacerlo en dispositivos físico, los emuladores suelen no ser fieles a la hora de identificar algunos de los paquetes que usamos. Si tienes algún problema no dudes en contactarme, estaré feliz de ayudarte en lo que me sea posible. Un abrazo y hasta la próxima.

Fuente: Medium.