Code With Bisky

Unleashing Voice Calling Power: Flutter + Appwrite + WebRTC | Free & Provider-Free Implementation Episode [20]

Key Topics covered:

  • Implement voice calls
  • Create call room
  • Join room
  • WebRTC Knowledge

Description:

In this tutorial, we will guide you through the process of integrating WebRTC into your Flutter app, allowing you to establish high-quality voice calls. The best part? We'll achieve all of this without relying on any paid service providers. Instead, we'll leverage the power of Flutter WebRTC and Appwrite, a powerful open-source backend platform. Throughout this tutorial, we'll dive into the details of setting up WebRTC in Flutter and explore the various functionalities it offers for seamless voice communication. By the end of the video, you'll have a solid understanding of how to implement voice calls using Flutter and WebRTC, all while utilizing Appwrite as your backend solution.

Add the following flutter_background_service dependency in your pubspec.yaml. This is the dependency that we are using to handle Voice Calls in flutter


dependencies:
  .........
  flutter_webrtc: ^0.9.35
  flutter_ringtone_player: ^3.2.0
        
        

Code Snippet(Android) configurations:

  • android/app/build.gradle
  • android/app/src/main/AndroidManifest.xml

minSdkVersion 21


    <uses-permission android:name="android.permission.READ_CONTACTS" />
    <uses-permission android:name="android.permission.WRITE_CONTACTS" />
    <uses-feature android:name="android.hardware.camera" />
    <uses-feature android:name="android.hardware.camera.autofocus" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
 
        

Code Snippet(iOS) configurations:

  • ios/Podfile

        target.build_configurations.each do |config|
              config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
              config.build_settings["ONLY_ACTIVE_ARCH"] = "YES"
              config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'NO'
          end

 
        

Code Snippet(strings.dart):

  • Add the following code below in lib/constant/strings.dart. Create collection on Appwrite dashboard

  static const collectionRoomId = "64b20a417c0155087ced";
  static const collectionCallerId = "64b20afc1058bd56a1d2";
  static const collectionCalleeId = "64b20b86a114c2d669fb";
        
        

Code Snippet(strings.dart):

  • Modify the following file lib/core/providers/AppwriteClientProvider.dart
  • Modify the following file lib/core/providers/RealtimeProvider.dart

We must have one Appwrite Client through the application. We need to update everywhere we are using provider client replace ref.read(appwriteClientProvider) with the code below

  • lib/core/providers/AppwriteClientProvider.dart
  • lib/core/providers/DatabaseProvider.dart
  • lib/core/providers/StorageProvider.dart

clientService.getClient()
        
        

Code Snippet(RealtimeNotifierProvider.dart):

  • Modify the following file lib/core/providers/RealtimeNotifierProvider.dart

Let's add voice calls listeners channels


const channels = [........
'databases.${Strings.databaseId}.collections.${Strings.collectionCalleeId}.documents',
'databases.${Strings.databaseId}.collections.${Strings.collectionCallerId}.documents',
'databases.${Strings.databaseId}.collections.${Strings.collectionRoomId}.documents',
            ]
        
        

Code Snippet(RoomAppwrite.dart):

  • Add the following file lib/model/RoomAppwrite.dart

We create a room before we create an offer and save the caller offer SDP and later on the callee answer sdp


import 'package:json_annotation/json_annotation.dart';

part 'RoomAppwrite.g.dart';

@JsonSerializable()
class RoomAppwrite {

  String? roomId;
  bool? groupCall;
  String? callType;
  String? rtcSessionDescription;
  String? callerUserId;
  String? calleeUserId;
  String? status;

  RoomAppwrite({this.roomId,this.groupCall, this.callType, this.rtcSessionDescription,this.callerUserId,this.calleeUserId,this.status});
  factory RoomAppwrite.fromJson(Map<String, dynamic> json) =>
      _$RoomAppwriteFromJson(json);
  Map<String, dynamic> toJson() => _$RoomAppwriteToJson(this);
}

        
        

Code Snippet(RTCSessionDescriptionModel.dart):

  • Add the following file lib/model/RTCSessionDescriptionModel.dart

Create an RTCSessionDescriptionModel that we are going to encode and decode to json


import 'package:json_annotation/json_annotation.dart';

part 'RTCSessionDescriptionModel.g.dart';

@JsonSerializable()
class RTCSessionDescriptionModel {

  String? type;
  String? description;

  RTCSessionDescriptionModel({this.type,this.description});
  factory RTCSessionDescriptionModel.fromJson(Map<String, dynamic> json) =>
      _$RTCSessionDescriptionModelFromJson(json);
  Map<String, dynamic> toJson() => _$RTCSessionDescriptionModelToJson(this);
}
        
        

Code Snippet(CandidateModel.dart):

  • Add the following file lib/model/CandidateModel.dart

Create a CandidateModel. The callee and caller candidates must be saved and used by each other to establish communication


import 'package:json_annotation/json_annotation.dart';

part 'CandidateModel.g.dart';

@JsonSerializable()
class CandidateModel {

  String? id;
  String? candidate;
  int? sdpMLineIndex;
  String? sdpMid;
  String? roomId;

  CandidateModel({this.id,this.candidate, this.sdpMLineIndex, this.sdpMid,this.roomId});
  factory CandidateModel.fromJson(Map<String, dynamic> json) =>
      _$CandidateModelFromJson(json);
  Map<String, dynamic> toJson() => _$CandidateModelToJson(this);
}        
        

Don't forget to run the following command in your terminal to generate new parts flutter packages pub run build_runner build

Code Snippet(main.dart):

  • Modify the following file lib/main.dart

Remove below code. We don't need it.


      notificationChannelId: 'my_foreground',
      initialNotificationTitle: 'AWESOME SERVICE',
      initialNotificationContent: 'Initializing',
     
        

Code Snippet(UserRepositoryProvider.dart):

  • Add the following file lib/core/providers/UserRepositoryProvider.dart

Add code to get user by id


import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/DatabaseProvider.dart';
import 'package:chat_with_bisky/model/UserAppwrite.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final userRepositoryProvider =
    Provider((ref) => UserDataRepositoryProvider(ref));

class UserDataRepositoryProvider {
  final Ref _ref;
  Databases get _db => _ref.read(databaseProvider);
  UserDataRepositoryProvider(this._ref);
  Future<UserAppwrite?> getUser(String id) async {
    try {
      Document document = await _db.getDocument(
          databaseId: Strings.databaseId,
          collectionId: Strings.collectionUsersId,
          documentId: id);
      return UserAppwrite.fromJson(document.data);
    } on AppwriteException catch (e) {
      print('ERROR getUser $e');
    } catch (e) {
      print('ERROR getUser $e');
    }
    return null;
  }
}     
        

Code Snippet(RoomRepositoryProvider.dart):

  • Add the following file lib/core/providers/RoomRepositoryProvider.dart

Add code create room, update sdps, create callee and caller candidates


import 'dart:convert';

import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/DatabaseProvider.dart';
import 'package:chat_with_bisky/model/CandidateModel.dart';
import 'package:chat_with_bisky/model/RTCSessionDescriptionModel.dart';
import 'package:chat_with_bisky/model/RoomAppwrite.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:realm/realm.dart';

final roomRepositoryProvider = Provider((ref) => RoomRepositoryProvider(ref));

class RoomRepositoryProvider {
  final Ref _ref;

  Databases get _db => _ref.read(databaseProvider);

  RoomRepositoryProvider(this._ref);

  Future<RoomAppwrite?> getRoom(String roomId) async {
    try {
      Document document = await _db.getDocument(
          databaseId: Strings.databaseId,
          collectionId: Strings.collectionRoomId,
          documentId: roomId);

      return RoomAppwrite.fromJson(document.data);
    } on AppwriteException catch (e) {
      print('ERROR getRoom $e');
    } catch (e) {
      print('ERROR getRoom $e');
    }

    return null;
  }

  Future<CandidateModel?> addCallerCandidates(
      RoomAppwrite roomAppwrite, RTCIceCandidate rtcIceCandidate) async {

    final model = CandidateModel(
        id: ObjectId().hexString,
        candidate: rtcIceCandidate.candidate,
        sdpMid: rtcIceCandidate.sdpMid,
        roomId: roomAppwrite.roomId,
        sdpMLineIndex: rtcIceCandidate.sdpMLineIndex);

    try {
      print('addCallerCandidates>>() ${model.toJson()}');
      Document document = await _db.createDocument(
          databaseId: Strings.databaseId,
          collectionId: Strings.collectionCallerId,
          documentId: ObjectId().hexString,
          data: model.toJson());

      print('addCallerCandidates Created CallerId   ${document.$id}');
      return CandidateModel.fromJson(document.data);
    } catch (e) {
      print('addCallerCandidates ERROR RoomRepositoryProvider $e');
    }
    return null;
  }

  Future<CandidateModel?> addCalleeCandidates(
      RoomAppwrite roomAppwrite, RTCIceCandidate rtcIceCandidate) async {

    final model = CandidateModel(
        id: ObjectId().hexString,
        roomId: roomAppwrite.roomId,
        candidate: rtcIceCandidate.candidate,
        sdpMid: rtcIceCandidate.sdpMid,
        sdpMLineIndex: rtcIceCandidate.sdpMLineIndex);

    try {
      Document document = await _db.createDocument(
          databaseId: Strings.databaseId,
          collectionId: Strings.collectionCalleeId,
          documentId: ObjectId().hexString,
          data: model.toJson());
      return CandidateModel.fromJson(document.data);
    } on AppwriteException  catch (e) {
      print('addCalleeCandidates ERROR createCallerUserData message= ${e.message} code=${e.code} response=${e.response} type=${e.type}');
    }catch (e) {
      print('addCalleeCandidates ERROR createCallerUserData $e');
    }

    return null;
  }

  Future<RoomAppwrite> addRtcSessionDescription(
      RoomAppwrite roomAppwrite, RTCSessionDescription description) async {


    List<RTCSessionDescriptionModel> list = [];
    final model = RTCSessionDescriptionModel(
        type: description.type, description: description.sdp);

    RoomAppwrite? room = await getRoom(roomAppwrite.roomId!);

    if (room != null) {
      if (room.rtcSessionDescription == null) {
        list.add(model);
      } else {
        List<dynamic> jsonData = json.decode(room.rtcSessionDescription!);
        list = jsonData
            .map((item) => RTCSessionDescriptionModel.fromJson(item))
            .toList();

        bool exist = false;
        for (RTCSessionDescriptionModel md in list) {
          if (md.type == model.type) {
            exist = true;
            md = model;
          }
        }

        if (exist == false) {
          list.add(model);
        }
      }

      try {
        Document document = await _db.updateDocument(
            databaseId: Strings.databaseId,
            collectionId: Strings.collectionRoomId,
            documentId: roomAppwrite.roomId!,
            data: {
              'rtcSessionDescription': json.encode(list),
              'roomId': roomAppwrite.roomId!
            });
        return RoomAppwrite.fromJson(document.data);
      } catch (e) {
        print('addRtcSessionDescription ERROR createCallerUserData $e');
      }
    }

    return roomAppwrite;
  }

  Future<void> deleteRoom(String id) async {
    try {
      Document document = await _db.deleteDocument(
          databaseId: Strings.databaseId,
          collectionId: Strings.collectionRoomId,
          documentId: id);

      deleteCalleeCandidates(id);
      deleteCallerCandidates(id);



    } on AppwriteException catch (e) {
      print('ERROR deleteRoom $e');
    } catch (e) {
      print('ERROR deleteRoom $e');
    }
  }


  deleteCalleeCandidates(String roomId) async {
      try{
        DocumentList documentList = await _db.listDocuments(databaseId: Strings.databaseId,
            collectionId: Strings.collectionCalleeId,
            queries: [
              Query.equal("roomId", [roomId]),
              Query.limit(100)
            ]);

        if(documentList.total > 0){

          for(Document document in documentList.documents){

             await _db.deleteDocument(
                databaseId: Strings.databaseId,
                collectionId: Strings.collectionCalleeId,
                documentId: document.$id);
          }

        }

      }catch (e){

        print('delete callee candidates Exception $e');
      }
  }


  deleteCallerCandidates(String roomId) async {
    try{
      DocumentList documentList = await _db.listDocuments(databaseId: Strings.databaseId,
          collectionId: Strings.collectionCallerId,
          queries: [
            Query.equal("roomId", [roomId]),
            Query.limit(100)
          ]);

      if(documentList.total > 0){

        for(Document document in documentList.documents){

          await _db.deleteDocument(
              databaseId: Strings.databaseId,
              collectionId: Strings.collectionCallerId,
              documentId: document.$id);
        }

      }

    }catch (e){

      print('delete caller candidates Exception $e');
    }
  }

  Future<RoomAppwrite?> createNewRoom(RoomAppwrite callDataAppwrite) async {
    try {
      Document document = await _db.createDocument(
          databaseId: Strings.databaseId,
          collectionId: Strings.collectionRoomId,
          documentId: callDataAppwrite.roomId!,
          data: callDataAppwrite.toJson());

      return RoomAppwrite.fromJson(document.data);
    } catch (e) {
      print('ERROR createNewRoom $e');
    }

    return null;
  }
}
  
        

Code Snippet(DashboardPage.dart):

  • Modify the following file lib/pages/dashboard/DashboardPage.dart

We need to listen for incoming calls in dashboard page


void _listenIncomingCalls() {
    LocalStorageService.deleteKey(LocalStorageService.callInProgress);
    ref.listen<RealtimeNotifier?>(
        realtimeNotifierProvider.select((value) => value.asData?.value),
            (_, next) async {

          if (next?.document.$collectionId == Strings.collectionRoomId) {
            final roomState = RoomAppwrite.fromJson(next!.document.data);

            String username = await LocalStorageService.getString(LocalStorageService.userId) ?? "";

            if(username == roomState.calleeUserId && roomState.rtcSessionDescription != null && next.type != RealtimeNotifier.delete){

              final inProgress = await LocalStorageService.getString(LocalStorageService.callInProgress);

              if(inProgress !=null){
                // todo  update room with state repository
                return;
              }
              final caller =  await ref.read(userRepositoryProvider).getUser(roomState.callerUserId??"");

              if(caller != null){
                ref.invalidate(voiceCallsWebRtcProvider);
                Navigator.of(context)
                    .push(MaterialPageRoute(builder: (context) =>
                    VoiceCallingPage(
                      user: caller,
                      callStatus: CallStatus.ringing,
                      roomId: roomState.roomId ?? "",
                    )));

              }
            }
          }
        });
  }
        
        

Call the _listenIncomingCalls() in build method. _DashboardPageState must extends ConsumerState and also DashboardPage extends ConsumerStatefulWidget


class DashboardPage extends ConsumerStatefulWidget {
  @override
  ConsumerState<DashboardPage> createState() {
    return _DashboardPageState();
  }
}
class _DashboardPageState extends ConsumerState<DashboardPage> {

        

Code Snippet(MessageScreen.dart):

  • Modify the following file lib/pages/dashboard/chat/MessageScreen.dart

Let's modify the AppBar to have call icons.


AppBar(
          elevation: 0,
          automaticallyImplyLeading: false,
          backgroundColor: Colors.white,
          flexibleSpace: SafeArea(
            child: Container(
              padding: const EdgeInsets.only(right: 16),
              child: Row(
                children: <Widget>[
                  IconButton(
                    onPressed: () {
                      Navigator.pop(context);
                    },
                    icon: const Icon(
                      Icons.arrow_back,
                      color: Colors.black,
                    ),
                  ),
                  const SizedBox(
                    width: 2,
                  ),
                  FriendImage(friendUserId ?? ""),
                  const SizedBox(
                    width: 12,
                  ),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        Text(
                          displayName,
                          style: const TextStyle(
                              fontSize: 16, fontWeight: FontWeight.w600),
                        ),
                        const SizedBox(
                          height: 6,
                        ),
                        // Expanded(
                        //   child: Text(
                        //     "",
                        //     style: TextStyle(
                        //         color: Colors.grey.shade600, fontSize: 13),
                        //   ),
                        // ),
                      ],
                    ),
                  ),
                  GestureDetector(
                      onTap: () {
                        ref.invalidate(voiceCallsWebRtcProvider);
                        Navigator.of(context).push(MaterialPageRoute(
                            builder: (context) => VoiceCallingPage(
                              user: widget.friendUser,
                            )));
                      },
                      child: const Icon(
                        Icons.phone,
                        size: 27,
                        color: Colors.green,
                      )),
                  const SizedBox(
                    width: 16,
                  ),
                  GestureDetector(
                      onTap: () {},
                      child: const Icon(
                        Icons.video_call,
                        size: 27,
                        color: Colors.green,
                      ))
                ],
              ),
            ),
          ),
        ),


            // replace return ChatMessageItem( with the code below

    return ChatMessageItem(
      message: documentSnapshot,
      displayName: displayName,
      myMessage: documentSnapshot.senderUserId == myUserId,
    );
  }

       
        

Code Snippet(ChatListScreen.dart):

  • Modify the following file l lib/pages/dashboard/chat/list/ChatListScreen.dart

Modify MessageRoute to have all required added parameters.


 AutoRouter.of(context).push(MessageRoute(displayName: friend.displayName ?? "",
                                    myUserId:userId,
                                    friendUserId:friend.senderUserId ?? "",friendUser: UserAppwrite(userId: friend.senderUserId,
                                    name: friend.displayName)));

       
        

Code Snippet(LocalStorageService.dart):

  • Modify the following file lib/service/LocalStorageService.dart

Add below code


static String callInProgress = "CALL_IN_PROGRESS";
       
        

Code Snippet(countup.dart):

  • Add the following file lib/widget/countup.dart

ACreate a widget for count up timer


import 'package:flutter/widgets.dart';

class Countup extends StatefulWidget {
  final double begin;
  final double end;
  final int precision;
  final Curve curve;
  final Duration duration;
  final TextStyle? style;
  final TextAlign? textAlign;
  final TextDirection? textDirection;
  final Locale? locale;
  final bool? softWrap;
  final TextOverflow? overflow;
  final double? textScaleFactor;
  final int? maxLines;
  final String? semanticsLabel;
  final String prefix;
  final String suffix;

  Countup({
    Key? key,
    this.begin = 0,
    this.end = 86400,
    this.precision = 0,
    this.curve = Curves.linear,
    this.duration = const Duration(seconds: 86400),
    this.style,
    this.textAlign,
    this.textDirection,
    this.locale,
    this.softWrap,
    this.overflow,
    this.textScaleFactor,
    this.maxLines,
    this.semanticsLabel,
    this.prefix = '',
    this.suffix = '',
  }) : super(key: key);

  @override
  _CountupState createState() => _CountupState();
}

class _CountupState extends State<Countup> with TickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  double? _latestBegin;
  double? _latestEnd;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: widget.duration, vsync: this);
    _latestBegin = widget.begin;
    _latestEnd = widget.end;
  }

  @override
  Widget build(BuildContext context) {
    CurvedAnimation curvedAnimation =
    CurvedAnimation(parent: _controller, curve: widget.curve);
    _animation = Tween<double>(begin: widget.begin, end: widget.end)
        .animate(curvedAnimation);

    if (widget.begin != _latestBegin || widget.end != _latestEnd) {
      _controller.reset();
    }

    _latestBegin = widget.begin;
    _latestEnd = widget.end;
    _controller.forward();

    return _CountupAnimatedText(
      key: UniqueKey(),
      animation: _animation,
      precision: widget.precision,
      style: widget.style,
      textAlign: widget.textAlign,
      textDirection: widget.textDirection,
      locale: widget.locale,
      softWrap: widget.softWrap,
      overflow: widget.overflow,
      textScaleFactor: widget.textScaleFactor,
      maxLines: widget.maxLines,
      semanticsLabel: widget.semanticsLabel,
      prefix: widget.prefix,
      suffix: widget.suffix,
    );
  }
}

class _CountupAnimatedText extends AnimatedWidget {
  final RegExp reg = new RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))');

  final Animation<double> animation;
  final int precision;
  final TextStyle? style;
  final TextAlign? textAlign;
  final TextDirection? textDirection;
  final Locale? locale;
  final bool? softWrap;
  final TextOverflow? overflow;
  final double? textScaleFactor;
  final int? maxLines;
  final String? semanticsLabel;
  final String? prefix;
  final String? suffix;

  _CountupAnimatedText({
    Key? key,
    required this.animation,
    required this.precision,
    this.style,
    this.textAlign,
    this.textDirection,
    this.locale,
    this.softWrap,
    this.overflow,
    this.textScaleFactor,
    this.maxLines,
    this.semanticsLabel,
    this.prefix,
    this.suffix,
  }) : super(key: key, listenable: animation);

  String _printDuration(Duration duration) {
    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
    String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
    if(twoDigits(duration.inHours) == "00"){
      return "$twoDigitMinutes:$twoDigitSeconds";
    }else{
      return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
    }
  }

  @override
  Widget build(BuildContext context) => Text(_printDuration(Duration(seconds: animation.value.toInt())),
    style: style,
    textAlign: textAlign,
    textDirection: textDirection,
    locale: locale,
    softWrap: softWrap,
    overflow: overflow,
    textScaleFactor: textScaleFactor,
    maxLines: maxLines,
    semanticsLabel: semanticsLabel,
  );
} 
        

Code Snippet(VoiceCallingPage.dart):

  • Add the following file lib/pages/dashboard/chat/voice_calls/VoiceCallingPage.dart

Create VoiceCallingPage for calls


import 'dart:ui';


import 'package:chat_with_bisky/model/UserAppwrite.dart';
import 'package:chat_with_bisky/pages/dashboard/chat/voice_calls/VoiceCallsWebRTCHandler.dart';
import 'package:chat_with_bisky/service/LocalStorageService.dart';
import 'package:chat_with_bisky/widget/countup.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum CallStatus { calling, accepted, ringing, progress, connecting,disconnected }

class VoiceCallingPage extends ConsumerStatefulWidget {

  final UserAppwrite user;
  CallStatus? callStatus;
  final String? roomId;

  VoiceCallingPage(
      {Key? key, required this.user, this.callStatus, this.roomId})
      : super(key: key);

  @override
  _VoiceCallingPageState createState() => _VoiceCallingPageState();
}

class _VoiceCallingPageState extends ConsumerState<VoiceCallingPage> {
  late CallStatus callStatus;

  VoiceCallsWebRTCHandler? webrtcLogic;

  String? roomId;
  bool _isCaller = false;

  initializeWebRTC() async {
    await Future.delayed(const Duration(milliseconds: 500));
    LocalStorageService.putString(LocalStorageService.callInProgress, 'YES');
    webrtcLogic!.onLocalStream = ((stream) {

    });

    webrtcLogic?.onAddRemoteStream = ((stream) {

    });
    webrtcLogic?.onRTCPeerConnectionStateChange = ((state) {
      switch (state) {
        case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
        case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
        case RTCPeerConnectionState.RTCPeerConnectionStateDisconnected:
         if(mounted){
           setState(() {
             callStatus = CallStatus.disconnected;
           });
         }
          break;

        case RTCPeerConnectionState.RTCPeerConnectionStateNew:
          break;
        case RTCPeerConnectionState.RTCPeerConnectionStateConnecting:
          setState(() {
            callStatus = CallStatus.connecting;
          });
          break;
        case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
          if (_isCaller == true) {
            setState(() {
              callStatus = CallStatus.progress;
            });
          } else {
            setState(() {
              callStatus = CallStatus.accepted;
            });
          }

          break;
      }
    });

    webrtcLogic?.onRemoveRemoteStream = ((stream) {
    });
    webrtcLogic?.onCallHangUpTimeout = ((stream) {
      if (mounted) {

        Navigator.of(context).pop();
      }
    });
    webrtcLogic?.remoteCallHangUp = ((stream) {
      if (mounted) {
        Navigator.of(context).pop();
      }
    });

    if (callStatus == CallStatus.calling) {
      final userId =
          await LocalStorageService.getString(LocalStorageService.userId) ??
              "";
      roomId = await webrtcLogic?.createRoom(userId, widget.user.userId ?? "");
      print("roomID: $roomId");

      setState(() {
        _isCaller = true;
      });
    }else if (callStatus == CallStatus.ringing){

      if(!_isCaller) {

        webrtcLogic?.roomId = roomId;
        FlutterRingtonePlayer.playRingtone();
        webrtcLogic?.callingCountDownTimeout();
      }

    }

    if (kDebugMode) {
      print("connected successfully");
    }
  }

  disconnectCall() async {
    await webrtcLogic?.hangUp();
    Navigator.of(context).pop();
  }

  @override
  void initState() {
    callStatus = widget.callStatus ?? CallStatus.calling;

    initializeWebRTC();
    super.initState();
  }

  @override
  void dispose() {
    webrtcLogic?.hangUp();
    if(!_isCaller){
      FlutterRingtonePlayer.stop();
    }

    super.dispose();
  }

  Widget getBody() {
    switch (callStatus) {
      case CallStatus.calling:
        return Center(
          child: Column(
            children: [
              const SizedBox(
                height: 100,
              ),
              Column(
                children: [
                  const SizedBox(
                    width: 150,
                    height: 150,

                  ),
                  const SizedBox(
                    height: 16,
                  ),
                  Text(
                    widget.user.name ?? "",
                    style: const TextStyle(
                        color: Colors.black,
                        fontSize: 26,
                        fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(
                    height: 8,
                  ),
                  const Text(
                    "Calling...",
                    style: TextStyle(
                        color: Colors.white,
                        shadows: [
                          BoxShadow(color: Colors.black, blurRadius: 3)
                        ],
                        fontSize: 16),
                  ),
                  GestureDetector(
                    onTap: () {
                      disconnectCall();
                    },
                    child: Column(
                      children: [
                        Container(
                          width: 70,
                          height: 70,
                          decoration: const BoxDecoration(
                              color: Colors.redAccent, shape: BoxShape.circle),
                          child: const Icon(Icons.phone_disabled,
                              color: Colors.white),
                        ),
                        const SizedBox(
                          height: 8,
                        ),
                        const Text(
                          "Decline",
                          style: TextStyle(color: Colors.white),
                        )
                      ],
                    ),
                  ),
                ],
              ),
            ],
          ),
        );
      case CallStatus.accepted:
        return Center(
          child: Column(
            children: [
              const SizedBox(
                height: 100,
              ),
              Column(
                children: [
                  Container(
                    width: 150,
                    height: 150,

                  ),
                  const SizedBox(
                    height: 16,
                  ),
                  Text(
                    widget.user.name ?? "",
                    style: const TextStyle(
                        color: Colors.black,
                        fontSize: 26,
                        fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(
                    height: 8,
                  ),
                  Countup(
                    style: const TextStyle(
                        color: Colors.white,
                        shadows: [
                          BoxShadow(color: Colors.black, blurRadius: 3)
                        ],
                        fontSize: 16),
                  ),
                  GestureDetector(
                    onTap: () {
                      disconnectCall();
                    },
                    child: Column(
                      children: [
                        Container(
                          width: 70,
                          height: 70,
                          decoration: const BoxDecoration(
                              color: Colors.redAccent, shape: BoxShape.circle),
                          child: const Icon(Icons.phone_disabled,
                              color: Colors.white),
                        ),
                        const SizedBox(
                          height: 8,
                        ),
                        const Text(
                          "End",
                          style: TextStyle(color: Colors.white),
                        )
                      ],
                    ),
                  ),
                ],
              ),
            ],
          ),
        );
      case CallStatus.ringing:
        return Column(
          children: [
            const Spacer(),
            Column(
              children: [
                const SizedBox(
                  width: 150,
                  height: 150,
                ),
                const SizedBox(
                  height: 16,
                ),
                Text(
                  widget.user.name ?? "",
                  style: const TextStyle(
                      color: Colors.black,
                      fontSize: 26,
                      fontWeight: FontWeight.bold),
                ),
                const SizedBox(
                  height: 8,
                ),
                const Text(
                  "Ringing...",
                  style: TextStyle(
                      color: Colors.white,
                      shadows: [BoxShadow(color: Colors.black, blurRadius: 3)],
                      fontSize: 16),
                )
              ],
            ),
            const SizedBox(
              height: 60,
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                GestureDetector(
                  onTap: () {
                    FlutterRingtonePlayer.stop();
                    webrtcLogic!.callingCountDownTimer?.cancel();
                    roomId = widget.roomId;
                    webrtcLogic?.joinRoom(roomId!);
                    setState(() {
                      callStatus = CallStatus.accepted;
                    });
                  },
                  child: Column(
                    children: [
                      Container(
                        width: 70,
                        height: 70,
                        decoration: const BoxDecoration(
                            color: Colors.green, shape: BoxShape.circle),
                        child: const Icon(Icons.phone, color: Colors.white),
                      ),
                      const SizedBox(
                        height: 8,
                      ),
                      const Text(
                        "Accept",
                        style: TextStyle(color: Colors.white),
                      )
                    ],
                  ),
                ),
                GestureDetector(
                  onTap: () {
                    disconnectCall();
                  },
                  child: Column(
                    children: [
                      Container(
                        width: 70,
                        height: 70,
                        decoration: const BoxDecoration(
                            color: Colors.redAccent, shape: BoxShape.circle),
                        child: const Icon(Icons.phone_disabled,
                            color: Colors.white),
                      ),
                      const SizedBox(
                        height: 8,
                      ),
                      const Text(
                        "Decline",
                        style: TextStyle(color: Colors.white),
                      )
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(
              height: 60,
            ),
            const Text(
              "Decline & Send Message",
              style: TextStyle(color: Colors.white60, fontSize: 14),
            ),
            const SizedBox(
              height: 10,
            ),

            const SizedBox(
              height: 80,
            ),
          ],
        );
      case CallStatus.connecting:
      case CallStatus.progress:
      case CallStatus.disconnected:
        return Center(
          child: Column(
            children: [
              const SizedBox(
                height: 100,
              ),
              Column(
                children: [
                  const SizedBox(
                    width: 150,
                    height: 150,
                  ),
                  const SizedBox(
                    height: 16,
                  ),
                  Text(
                    widget.user.name ?? "",
                    style: const TextStyle(
                        color: Colors.black,
                        fontSize: 26,
                        fontWeight: FontWeight.bold),
                  ),
                  if (CallStatus.connecting == callStatus)
                    const Text(
                      "Connecting...",
                      style: TextStyle(
                          color: Colors.black,
                          fontSize: 14,
                          fontWeight: FontWeight.bold),
                    ),
                  if (CallStatus.disconnected == callStatus)
                    const Text(
                      "You are disconnected. Please tap End Call Button...",
                      style: TextStyle(
                          color: Colors.black,
                          fontSize: 14,
                          fontWeight: FontWeight.bold),
                    ),
                  const SizedBox(
                    height: 8,
                  ),
                  Countup(
                    style: const TextStyle(
                        color: Colors.white,
                        shadows: [
                          BoxShadow(color: Colors.black, blurRadius: 3)
                        ],
                        fontSize: 16),
                  ),
                  GestureDetector(
                    onTap: () {
                      disconnectCall();
                    },
                    child: Column(
                      children: [
                        Container(
                          width: 70,
                          height: 70,
                          decoration: const BoxDecoration(
                              color: Colors.redAccent, shape: BoxShape.circle),
                          child: const Icon(Icons.phone_disabled,
                              color: Colors.white),
                        ),
                        const SizedBox(
                          height: 8,
                        ),
                        const Text(
                          "End",
                          style: TextStyle(color: Colors.white),
                        )
                      ],
                    ),
                  ),
                ],
              ),
            ],
          ),
        );
    }
  }

  @override
  Widget build(BuildContext context) {
    webrtcLogic = ref.read(voiceCallsWebRtcProvider);
    return Scaffold(
      body: Stack(
        children: [
          SizedBox(
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height,
            child: BackdropFilter(
              filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
              child: Container(
                decoration: BoxDecoration(color: Colors.white.withOpacity(0.0)),
              ),
            ),
          ),
          getBody(),
        ],
      ),
    );
  }

  @override
  void setState(VoidCallback fn) {
    if(mounted){
      super.setState(fn);
    }

  }
} 
        

Code Snippet(countup.dart):

  • Add the following file lib/widget/countup.dart

ACreate a widget for count up timer


static String callInProgress = "CALL_IN_PROGRESS";
       
        

Code Snippet(VoiceCallsWebRTCHandler.dart):

  • Add the following file lib/pages/dashboard/chat/voice_calls/VoiceCallsWebRTCHandler.dart

A handler that creates room and handle call connections. It updates Caller and Callee candidates and listens to their activity on call


import 'dart:async';
import 'dart:convert';

import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/RealtimeNotifierProvider.dart';
import 'package:chat_with_bisky/core/providers/RoomRepositoryProvider.dart';
import 'package:chat_with_bisky/model/CandidateModel.dart';
import 'package:chat_with_bisky/model/RTCSessionDescriptionModel.dart';
import 'package:chat_with_bisky/model/RealtimeNotifier.dart';
import 'package:chat_with_bisky/model/RoomAppwrite.dart';
import 'package:chat_with_bisky/service/LocalStorageService.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:realm/realm.dart';

typedef StreamStateCallback = void Function(MediaStream stream);

final voiceCallsWebRtcProvider = Provider((ref) => VoiceCallsWebRTCHandler(ref));

class VoiceCallsWebRTCHandler {
  final Map<String, dynamic> configuration = {
    'iceServers': [
      {'url': 'stun:stun.l.google.com:19302'},
      { 'url': 'stun:stun1.l.google.com:19302' },
      { 'url': 'stun:stun2.l.google.com:19302' },
      { 'url': 'stun:stun3.l.google.com:19302' },
      {
        'url': 'turn:144.91.113.238:3478?transport=udp',
        'username': 'codewithbisky',
        'credential': 'webrtc'
      },
    ]
  };

  final Map<String, dynamic> _voiceConstraints = {
    'mandatory': {
      'OfferToReceiveAudio': true,
      'OfferToReceiveVideo': false,
    },
    'optional': [],
  };

  RTCPeerConnection? peerConnection;
  MediaStream? localStream;
  MediaStream? remoteStream;
  String? roomId;
  String? currentRoomText;
  String? callerType = 'voice';
  StreamStateCallback? onAddRemoteStream;
  StreamStateCallback? onRemoveRemoteStream;
  StreamStateCallback? onLocalStream;
  ValueChanged<RTCPeerConnectionState>? onRTCPeerConnectionStateChange;
  ValueChanged<bool>? onCallHangUpTimeout;
  ValueChanged<bool>? remoteCallHangUp;
  bool calleeAnswer = false;
  bool groupCall = false;
  bool addIntoList = true;
  bool isCaller = true;
  final List<RTCIceCandidate> _listLocalCandidates = [];
  final Ref _ref;
  List<MediaStream>? _remoteStreams;
  Timer? callingCountDownTimer;

  RoomRepositoryProvider get _roomRepository =>
      _ref.read(roomRepositoryProvider);

  VoiceCallsWebRTCHandler(this._ref);

  Future<MediaStream> createStream() async {
    var stream = await navigator.mediaDevices
        .getUserMedia({'video': false, 'audio': true});
    onLocalStream!(stream);
    return stream;
  }

  Future<RTCPeerConnection> _createPeerConnection() async {
    localStream = await createStream();
    RTCPeerConnection pc = await createPeerConnection(configuration);
    localStream?.getTracks().forEach((track) {
      pc.addTrack(track, localStream!);
    });

    return pc;
  }

  Future<String> createRoom(String callerUserId, String calleeUserId) async {
    final id = ObjectId().hexString;
    RoomAppwrite room = RoomAppwrite(
        roomId: id,
        callType: callerType,
        groupCall: groupCall,
        callerUserId: callerUserId,
        calleeUserId: calleeUserId);

    this.roomId = id;

    peerConnection = await _createPeerConnection();

    registerPeerConnectionListeners();

    await _roomRepository.createNewRoom(room);

    peerConnection?.onIceCandidate = (RTCIceCandidate candidate) async {
      if (addIntoList == true) {
        _listLocalCandidates.add(candidate);
      } else {
        await _roomRepository.addCallerCandidates(room, candidate);
      }
    };

    RTCSessionDescription offer =
        await peerConnection!.createOffer(_voiceConstraints);
    await peerConnection!.setLocalDescription(offer);

    await _roomRepository.addRtcSessionDescription(room, offer);

    var roomId = id;

    _syncChangesCaller(roomId, room);

    callingCountDownTimeout();

    return roomId;
  }

  Future<void> joinRoom(String roomId) async {
    this.roomId = roomId;
    RoomAppwrite? roomAppwrite = await _roomRepository.getRoom(roomId);
    if (roomAppwrite != null) {

      peerConnection = await _createPeerConnection();

      registerPeerConnectionListeners();

      // Code for collecting ICE candidates below
      peerConnection!.onIceCandidate = (RTCIceCandidate candidate) async {
        if (candidate.candidate == null) {
          return;
        }
        _listLocalCandidates.add(candidate);
        await _roomRepository.addCalleeCandidates(roomAppwrite, candidate);
      };

      final rtcSessionDescription = roomAppwrite.rtcSessionDescription;

      if (rtcSessionDescription != null) {
        List<dynamic> jsonData = json.decode(rtcSessionDescription);
        List<RTCSessionDescriptionModel> listRTCDescriptions = jsonData
            .map((item) => RTCSessionDescriptionModel.fromJson(item))
            .toList();


        RTCSessionDescriptionModel? rtcSessionDescriptionOffer = listRTCDescriptions.where((element) =>
        element.type == 'offer').firstOrNull;
        if(rtcSessionDescriptionOffer != null){
          var offer = RTCSessionDescription(
            rtcSessionDescriptionOffer.description,
            rtcSessionDescriptionOffer.type,
          );
          await peerConnection?.setRemoteDescription(offer);
        }

        var answer = await peerConnection!.createAnswer(_voiceConstraints);
        await peerConnection!.setLocalDescription(answer);
        await _roomRepository.addRtcSessionDescription(roomAppwrite, answer);
        _syncChangesCallee(roomId);
      }
    }
  }

  Future<void> hangUp() async {
    LocalStorageService.deleteKey(LocalStorageService.callInProgress);
    remoteStream?.getTracks().forEach((track) => track.stop());

    peerConnection?.close();

    if (roomId != null) {
      await _roomRepository.deleteRoom(roomId ?? "");
    }

    localStream?.dispose();
    remoteStream?.dispose();
  }

  void registerPeerConnectionListeners() {
    peerConnection?.onIceGatheringState = (RTCIceGatheringState state) {
    };

    peerConnection?.onConnectionState = (RTCPeerConnectionState state) {

      if (onRTCPeerConnectionStateChange != null) {
        onRTCPeerConnectionStateChange!(state);
      }
    };

    peerConnection?.onSignalingState = (RTCSignalingState state) {
    };

    peerConnection?.onIceGatheringState = (RTCIceGatheringState state) {
    };

    peerConnection?.onAddStream = (MediaStream stream) {
      if (onAddRemoteStream != null) onAddRemoteStream!(stream);
    };

    peerConnection?.onRemoveStream = (stream) {
      if (onRemoveRemoteStream != null) {
        onRemoveRemoteStream!(stream);
      }
      _remoteStreams?.removeWhere((MediaStream it) {
        return (it.id == stream.id);
      });
    };
  }

  void _syncChangesCaller(String roomId, RoomAppwrite room) async {
    _ref.listen<RealtimeNotifier?>(
        realtimeNotifierProvider.select((value) => value.asData?.value),
        (_, next) async {
      if (next?.document.$collectionId == Strings.collectionRoomId) {
        final roomState = RoomAppwrite.fromJson(next!.document.data);
        if (roomState.roomId == roomId) {
          String userId = await LocalStorageService.getString(LocalStorageService.userId) ?? "";
          if(roomState.roomId == roomId && (userId == roomState.calleeUserId || userId == roomState.callerUserId) && next.type == RealtimeNotifier.delete){

            remoteCallHangUp!(true);
            return;
          }
          final rtcSessionDescription = roomState.rtcSessionDescription;

          if (rtcSessionDescription != null) {

            List<dynamic> jsonData = json.decode(rtcSessionDescription);
            List<RTCSessionDescriptionModel> listRTCDescriptions = jsonData
                .map((item) => RTCSessionDescriptionModel.fromJson(item))
                .toList();

            if (roomState.status == 'busy') {
              return;
            }

            if (calleeAnswer == false) {

              RTCSessionDescriptionModel? rtcSessionDescriptionAnswer = listRTCDescriptions.where((element) => element.type == 'answer').firstOrNull;
              if(rtcSessionDescriptionAnswer != null){
                calleeAnswer = true;
                var answer = RTCSessionDescription(
                  rtcSessionDescriptionAnswer.description,
                  rtcSessionDescriptionAnswer.type,
                );
                await peerConnection?.setRemoteDescription(answer);
                callingCountDownTimer?.cancel();
                if (_listLocalCandidates.isNotEmpty) {
                  for (RTCIceCandidate candidate in _listLocalCandidates) {
                    await _roomRepository.addCallerCandidates(
                        room, candidate);
                  }
                }
                addIntoList = false;
              }
            }
          }
        }
      } else if (next?.document.$collectionId == Strings.collectionCalleeId) {
        // Listen for remote Ice candidates below
        final roomState = CandidateModel.fromJson(next!.document.data);
        if (roomState.roomId == roomId &&
            next.type == RealtimeNotifier.create) {
          peerConnection!.addCandidate(
            RTCIceCandidate(
              roomState.candidate,
              roomState.sdpMid,
              roomState.sdpMLineIndex,
            ),
          );
        }
      }
    });
  }

  void _syncChangesCallee(String roomId) async {
    _ref.listen<RealtimeNotifier?>(
        realtimeNotifierProvider.select((value) => value.asData?.value),
        (_, next) async {
      if (next?.document.$collectionId == Strings.collectionCallerId) {
        final roomState = CandidateModel.fromJson(next!.document.data);

        if (roomState.roomId == roomId &&
            next.type == RealtimeNotifier.create) {
          peerConnection!.addCandidate(
            RTCIceCandidate(
              roomState.candidate,
              roomState.sdpMid,
              roomState.sdpMLineIndex,
            ),
          );
        }
      }else   if (next?.document.$collectionId == Strings.collectionRoomId && next?.type != RealtimeNotifier.delete) {

        final roomState = RoomAppwrite.fromJson(next!.document.data);

        String userId = await LocalStorageService.getString(LocalStorageService.userId) ?? "";

        if(roomState.roomId == roomId && (userId == roomState.calleeUserId || userId == roomState.callerUserId) && next.type == RealtimeNotifier.delete){

          remoteCallHangUp!(true);

        }

      }
    });
  }

  callingCountDownTimeout() {
    callingCountDownTimer = Timer(const Duration(minutes: 1), () {
      onCallHangUpTimeout!(true);
    });
  }

}

       
        

Conclusion:

We managed to implement voice calls with flutter WebRTC. The users can call each other communicate for free without any paid service provider. In the next tutorial, we are going to implement Firebase Cloud Messaging Integration in Flutter. Don't forget to share and join our Discord Channel. May you please subscribe to our YouTube Channel.