In today’s connected world, building a seamless video calling experience is more important than ever. If you're looking to create a cross-platform video calling app with native call-trigger functionality—just like a regular phone call—you’re in the right place.
In this tutorial, we’ll walk through building a powerful video calling app using:
- Flutter for the cross-platform UI
- Node.js for handling server-side token generation
- Firebase for real-time user status and call event synchronization
- VideoSDK for high-quality, low-latency audio and video communication
- Android’s Telecom Framework to provide native call UI and call management behavior
By the end, you’ll have a production-ready video calling experience complete with incoming/outgoing call screens, call controls, and real-time updates. Be sure to check out the sample code and demo video to see the final result in action.
Introduction to Telecom Framework
The Telecom framework in Android facilitates seamless management of audio and video calls, supporting both traditional SIM-based calls and VoIP functionality. It acts as the backbone for handling call connections and user interactions during calls.
Key Components:
- ConnectionService: Manages call connections, tracks their states, and ensures proper routing of audio and video streams.
- InCallService: Provides the interface for call interactions, enabling users to view, answer, and manage ongoing calls.
A thorough understanding of these components ensures a smoother development process when integrating call functionality into Android applications.
Overview of the Application for Android
The application facilitates seamless video calling by leveraging a well-coordinated workflow between Flutter, Node.js, Firebase, and platform-specific features. Here’s an overview of the call process:
Initiating a Call
- The caller (e.g., Pavan) enters the recipient's unique ID (e.g., Jay) and presses the call button.
- This action triggers a server request to initiate the call, which sends a Firebase Cloud Messaging (FCM) notification to the recipient's device through an API.
Receiving the Call
- If the app is in the foreground: The notification is handled on the Flutter side, where a method channel is used to invoke Kotlin code for the Android
TelecomManager
. This displays the call interface. - If the app is in the background: The notification is processed by the
FirebaseMessagingService
in Kotlin. It then usesTelecomManager
to display the call interface.
Handling Call Status
Upon answering or rejecting the call, the recipient's action triggers a server request to update the call status. This update is communicated back to the caller (Pavan) to reflect the recipient's response.
Core Components of the App
The app integrates several core components to manage video calling and notifications seamlessly across Android platform:
Telecom Framework
- Purpose: Manages incoming and outgoing calls with native system integration.
- Function: Handles call connection states, audio/video routing, and user interactions through the Android
TelecomManager
.
Push Notifications
- Purpose: Notifies the recipient about incoming calls.
- Function: Uses Firebase Cloud Messaging (FCM), for triggering the appropriate call UI even if the app is in the background or inactive.
Firebase Realtime Database
- Purpose: Stores user tokens and caller IDs.
- Function: Ensures secure and real-time communication between users, facilitating the initiation of calls and status updates.
VideoSDK
- Purpose: Powers the video calling functionality.
- Function: Enables real-time video and audio communication, providing high-quality video conferencing features for both Android and iOS users.
Node.js Server
- Purpose: Acts as the backend service for call initiation and status updates.
- Function: Sends push notifications via Firebase and APNs, and updates call statuses (e.g., accepted, rejected) between users.
These core components work together to ensure a smooth, feature-rich experience for both video calling and real-time notifications across devices and platforms.
Prerequisites
Before starting with the development of the video calling app, ensure that the following prerequisites are met:
- Flutter Development Environment:
- Install Flutter SDK and set up your development environment. Follow the official Flutter installation guide.
- Firebase Project:
- Create a Firebase project in the Firebase Console.
- Set up Firebase Cloud Messaging (FCM) for push notifications.
- VideoSDK Account:
- Sign up for a VideoSDK account at VideoSDK and obtain the required credentials (API keys and authentication tokens).
- Node.js Server:
- Set up a Node.js server to manage API requests, handle call initiation, and send push notifications. This server will also interact with Firebase to send call-trigger notifications.
With these prerequisites in place, you'll be ready to begin implementing the video calling functionality, leveraging both Firebase and VideoSDK for a seamless cross-platform experience.
Connecting Firebase to Flutter Using FlutterFire CLI
To easily set up Firebase with your Flutter app, follow these steps:
Step 1: Create a Firebase Project
- Go to the Firebase Console.
- Create a new project by following the prompts.
Step 2: Select the Flutter Option
- Once your project is created, navigate to the "Add app" section.
- Choose the Flutter option to proceed.
Step 3: Install Firebase CLI
- Use npm to globally install the Firebase CLI. Run the following command in your terminal:
npm install -g firebase-tools
Step 4: Login to Firebase
- Log in to your Firebase account using the Firebase CLI by running:
firebase login
- This will open a browser window prompting you to sign in with your Google account.
Step 5: Configure Firebase in Flutter Using FlutterFire CLI
- Use the FlutterFire CLI to connect your Flutter project to Firebase
- Select an existing Firebase project or create a new one.
- Choose the platforms (e.g., Android, iOS) you want to integrate Firebase with.
- This will add new dart file named as firebase_option.dart in your project .
Step 6: Add dependency in pubspec.yaml file
- add firebase_core dependency in your pubspec.yaml file.
Project Structure
Client
│
├── android
│ └── app
│ └── src
│ ├── main
| ├── java
│ ├── kotlin/com/example/example
│ | ├── CallConnectionService.kt
│ | ├── MainActivity.kt
│ | └── MyFirebaseMessagingService.kt
│ └── res
├── lib
│ |
│ ├── meeting
│ | ├── api_call.dart
│ | ├── join_screen.dart
│ | ├── meeting_controls.dart
│ | ├── meeting_screen.dart
│ | └── participant_tile.dart
│ |
│ ├── firebase_options.dart
│ ├── home.dart
│ └── main.dart
│
├── .env
├── pubspec.yaml
└── README.md
Now start with android code
MainActivity.kt
MainActivity.kt
Established the bridge using MethodChannel for communication between Flutter and native side code .we use Android Telecom API to handle native calls . we register phoneAccount , manage incoming calls and open phone account settings when it requires . we also initialise the callconnetionservice.kt class and firebase also from this file .
package com.example.example
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import android.util.Log
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.lang.Exception
import com.google.firebase.FirebaseApp
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.example/calls"
private var telecomManager: TelecomManager? = null
private var phoneAccountHandle: PhoneAccountHandle? = null
private var methodChannel: MethodChannel? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
FirebaseApp.initializeApp(this)
Log.d("MainActivity", "Firebase Initialized")
telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
val componentName = ComponentName(this, CallConnectionService::class.java)
phoneAccountHandle = PhoneAccountHandle(componentName, "DhirajAccountId")
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
Log.d("MainActivity", "Above the call connection Service!!!")
CallConnectionService.setFlutterEngine(flutterEngine)
MyFirebaseMessagingService.setFlutterEngine(flutterEngine)
Log.d("MainActivity", "Below the call connection Service!!!")
methodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"registerPhoneAccount" -> {
try {
registerPhoneAccount()
result.success("Phone account registered successfully")
} catch (e: Exception) {
result.error("ERROR", "Failed to register phone account", e.message)
}
}
"handleIncomingCall" -> {
val callerId = call.argument<String>("callerId")
handleIncomingCall(callerId)
result.success("Incoming call handled successfully")
}
"openPhoneAccountSettings" ->
{
openPhoneAccountSettings()
result.success("Incoming call phone account");
}
else -> {
result.notImplemented()
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
Log.d("MainActivity", "Initializing Firebase")
super.onCreate(savedInstanceState)
}
override fun onPause() {
super.onPause()
Log.d("MainActivity", "App is paused. Performing necessary tasks.")
// Perform any cleanup or save operations
}
override fun onResume() {
super.onResume()
Log.d("MainActivity", "App is resumed. Restoring state or resources.")
// Restore any states or resources
}
override fun onStop() {
super.onStop()
Log.d("MainActivity", "App is stopped. Releasing resources.")
// Check for incoming call and handle it
val callerId = intent.getStringExtra("callerId") // Correct key
Log.d("MainActivity", "Incoming call from: $callerId")
if (callerId != null) {
handleIncomingCall(callerId)
}
}
override fun onStart() {
super.onStart()
Log.d("MainActivity", "App is started. Preparing resources.")
// Initialize or prepare resources
}
// Register the phone account with the telecom manager
private fun registerPhoneAccount() {
val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "VideoSdk")
.setCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER)
.build()
telecomManager?.registerPhoneAccount(phoneAccount)
}
// Check if the phone account is registered
private fun isPhoneAccountRegistered(): Boolean {
val phoneAccounts = telecomManager?.callCapablePhoneAccounts ?: return false
return phoneAccounts.contains(phoneAccountHandle)
}
// Handle the incoming call or open settings if the account is not registered
private fun handleIncomingCall(callerId: String?) {
if (!isPhoneAccountRegistered()) {
// Open phone account settings if not registered
openPhoneAccountSettings()
return
}
val extras = Bundle().apply {
val uri = Uri.fromParts("tel", callerId, null)
putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, uri)
}
try {
telecomManager?.addNewIncomingCall(phoneAccountHandle, extras)
} catch (cause: Throwable) {
Log.e("handleIncomingCall", "Error in addNewIncomingCall", cause)
}
}
// Simulate an incoming call (e.g., during app stop)
private fun simulateIncomingCall() {
Log.d("MainActivity", "Simulating an incoming call in onStop.")
// Simulate a caller ID for testing
val testCallerId = "1234567890"
handleIncomingCall(testCallerId)
}
// Open phone account settings
private fun openPhoneAccountSettings() {
try {
val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
} catch (e: Exception) {
Log.e("openPhoneAccountSettings", "Unable to open settings", e)
}
}
}
CallConnectionService.kt
CallConnectionService acts as the bridge between the Android Telecom framework and the flutter app, managing incoming call events. It creates a Connection object to handle call lifecycle actions like answering or rejecting calls , also seamlessly communicating these events back to Flutter via a method channel .
package com.example.example
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import android.telecom.Connection
import android.telecom.ConnectionRequest
import android.telecom.ConnectionService
import android.telecom.TelecomManager
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
class CallConnectionService : ConnectionService() {
companion object {
private const val CHANNEL = "com.example.example/calls"
private var methodChannel: MethodChannel? = null
fun setFlutterEngine(flutterEngine: FlutterEngine) {
methodChannel = MethodChannel(flutterEngine.dartExecutor, CHANNEL)
}
}
override fun onCreateIncomingConnection(
connectionManagerPhoneAccount: android.telecom.PhoneAccountHandle?,
request: ConnectionRequest?
): Connection {
val connection = object : Connection() {
override fun onAnswer() {
super.onAnswer()
val extras = request?.extras
val roomId = extras?.getString("roomId")
val callerId = extras?.getString("callerId")
Log.d("CallConnectionService", "Call Answered")
Log.d("Room ID:", roomId ?: "null")
Log.d("Caller ID:", callerId ?: "null")
// Generate deep link for the meeting screen
val deepLink = "exampleapp://open/meeting?roomId=$roomId&callerId=$callerId" //creating the deeplink
val intent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
data = Uri.parse(deepLink)
}
startActivity(intent) //again triggering the intent for the //connection with Fluter.
destroy()
}
override fun onReject() {
super.onReject()
val extras = request?.extras
val callerId = extras?.getString("callerId")
// Generate deep link for the home screen
val deepLink = "exampleapp://open/home?callerId=$callerId" //same fir the reject call
val intent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
data = Uri.parse(deepLink)
}
startActivity(intent) //again triggering the intent for the connection with Fluter.
destroy()
}
}
connection.setAddress(request?.address, TelecomManager.PRESENTATION_ALLOWED)
connection.setCallerDisplayName("Incoming Call", TelecomManager.PRESENTATION_ALLOWED)
connection.setInitializing()
connection.setActive()
return connection
}
}
MyFirebaseMessagingService.kt
The MyFirebaseMessagingService handle incoming firebase notification only for background like when the app is in background , and also make call from here
package com.example.example
import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import android.content.pm.PackageManager
import android.Manifest
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.os.Handler
import android.os.Looper
class MyFirebaseMessagingService : FirebaseMessagingService() {
companion object {
private const val CHANNEL_ID = "call_notifications_channel"
private const val TAG = "MyFirebaseMessagingService"
private const val FLUTTER_CHANNEL = "ack"
var methodChannel: MethodChannel? = null
fun setFlutterEngine(flutterEngine: FlutterEngine) {
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FLUTTER_CHANNEL)
}
}
private var telecomManager: TelecomManager? = null
private var phoneAccountHandle: PhoneAccountHandle? = null
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
Log.d(TAG, "Received notification data: $data")
if (data.isNotEmpty()) {
val callerId = data["callerInfo"]
val roomId = data["roomId"]
val type = data["type"]
if (callerId != null && roomId != null && type == null) {
handleIncomingCall(callerId, roomId)
// Send data to Flutter on the main thread
Handler(Looper.getMainLooper()).post {
sendToFlutter(callerId, roomId)
}
}
}
}
private fun sendToFlutter(callerId: String, roomId: String) {
if (methodChannel != null) {
// Ensure this runs on the main thread
Handler(Looper.getMainLooper()).post {
methodChannel?.invokeMethod("onIncomingCall", mapOf("callerId" to callerId, "roomId" to roomId))
}
} else {
Log.e(TAG, "MethodChannel is not initialized.")
}
}
private fun handleIncomingCall(callerId: String, roomId: String) {
initializeTelecomManager() // Ensure telecomManager and phoneAccountHandle are initialized
if (!isPhoneAccountRegistered()) {
Log.w(TAG, "Phone account not registered. Cannot handle incoming call.")
return
}
if (!hasReadPhoneStatePermission()) {
Log.e(TAG, "READ_PHONE_STATE permission not granted.")
return
}
val extras = Bundle().apply {
val uri = Uri.fromParts("tel", callerId, null)
putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, uri)
putString("roomId",roomId)
putString("callerId",callerId)
}
try {
telecomManager?.addNewIncomingCall(phoneAccountHandle, extras)
Log.d(TAG, "Incoming call handled successfully for Caller ID: $callerId")
} catch (cause: Throwable) {
Log.e(TAG, "Error handling incoming call", cause)
}
}
private fun initializeTelecomManager() {
if (telecomManager == null || phoneAccountHandle == null) {
telecomManager = getSystemService(Context.TELECOM_SERVICE) as TelecomManager
val componentName = ComponentName(this, CallConnectionService::class.java)
phoneAccountHandle = PhoneAccountHandle(componentName, "DhirajAccountId")
Log.d(TAG, "TelecomManager and PhoneAccountHandle initialized.")
}
}
private fun isPhoneAccountRegistered(): Boolean {
val phoneAccounts = telecomManager?.callCapablePhoneAccounts ?: return false
return phoneAccounts.contains(phoneAccountHandle)
}
private fun hasReadPhoneStatePermission(): Boolean {
return ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED
}
private fun isAppInBackground(): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val runningAppProcesses = activityManager.runningAppProcesses ?: return true
for (processInfo in runningAppProcesses) {
if (processInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
&& processInfo.processName == packageName
) {
return false // App is in foreground
}
}
return true // App is in background
}
}
Now go to the server side and setup our server
Step 1 : Create a new project Directory
mkdir server
cd server
npm init -y
Step 2 : Install required dependencies
npm install express cors morgan firebase-admin uuid
Step 3 : Set Up Firebase
Enable Realtime Database and set the rule true and Firebase Cloud messaging (FCM).
Step 4 :
Download the private key by navigating to Project Settings > Service Accounts, selecting Node.js, and then clicking Download.
Step 5 :
Place this File into your server side and your server side structure look like this.
Server
├── node_modules/
├── server.js
├── callkit-3ec73-firebase-adminsdk-ghfto-9d9fc7a362.json
├── package-lock.json
└── package.json
Step 6 : now go to server.js we have to add several API in our server.js
- GET /: Returns a "Hello Coding" message to confirm the server is running.
- POST /register-device: Registers a device by storing its unique ID and FCM token in Firebase.
- POST /api/add: Stores call details (callerId, roomId, and calleeId) in memory.
- GET /api/getByCallee/:calleeId: Retrieves call details for a given callee ID from memory.
- POST /send-call-status: Sends a notification to the caller about the status of a call (e.g., accepted, rejected, ended).
- POST /send-notification: Sends a notification to a caller with details about an incoming call, including room ID and VideoSDK token.
Step 7 : Now we add below code to run the server at your ip
const PORT = process.env.PORT || 9000;
const LOCAL_IP = '10.0.0.161'; // Replace with your actual local IP address
app.listen(PORT, LOCAL_IP, () => {
console.log(Server running on http://${LOCAL_IP}:${PORT});
});
Step 8 :
we setup ngrok and using this below command we redirect our local ip to temporary public URL that can share outside to your local network ngrok http http://10.0.0.161:9000
It looks like that we can use this https://8190-115-246-20-252.ngrok-free.app as our server url.
Refer the complete code of server,js here.
VideoSDK is a cutting-edge platform that enables seamless audio and video calling with low latency and robust performance. Start by signing up at VideoSDK, generate your API token from the dashboard, and integrate real-time calling into your app effortlessly!
Now add this token into your .env file
Now add all permission for android. In android , we need permission of camera , audio, internet ,wake lock ,call log , post notification and more here is full file of AndroidManifest.xml .
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.example">
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<!-- Permissions -->
<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.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application
android:label="Flutter Telecom App"
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<!-- Main Activity -->
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<!-- Specifies an Android theme to apply to this Activity -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Launch screen configuration -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="exampleapp" android:host="open" />
</intent-filter>
</activity>
<!-- Telecom Connection Service -->
<service
android:name=".CallConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<service
android:name=".MyFirebaseMessagingService"
android:permission="com.google.android.c2dm.permission.RECEIVE"
android:exported="true">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Telecom Management Service -->
<service
android:name=".MyTelecomService"
android:exported="true" />
<!-- FlutterActivity declaration -->
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:label="Flutter"
android:theme="@style/LaunchTheme"
android:exported="true" />
<!-- Main Application Metadata -->
<meta-data android:name="flutterEmbedding" android:value="2" />
</application>
</manifest>
Okay, let's move forward with integrating the VideoSDK into our project. Please refer to the Quickstart documentation provided in the link and follow the initial steps to build the necessary steps till step 4 which is creating a paticipant_tile.dart screen.
Now, after adding the other files to the Meeting folder, lets create a meeting_screen.dart file through which our meeting will be created, rendered and managed.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:videosdk/videosdk.dart';
import 'package:videosdk_flutter_example/home.dart';
import 'package:videosdk_flutter_example/meeting/meeting_controls.dart';
import './participant_tile.dart';
import 'package:http/http.dart' as http;
class MeetingScreen extends StatefulWidget {
final String meetingId;
final String token;
final String url;
final String callerId;
String? source;
MeetingScreen(
{Key? key,
required this.meetingId,
required this.token,
required this.callerId,
required this.url,
this.source})
: super(key: key);
State<MeetingScreen> createState() => _MeetingScreenState();
}
class _MeetingScreenState extends State<MeetingScreen> {
late Room _room;
var micEnabled = true;
var camEnabled = true;
Map<String, Participant> participants = {};
void initState() {
// create room
if (widget.source == "true") {
sendnotification(
widget.url, widget.callerId, "Call Accepted", widget.meetingId);
}
_room = VideoSDK.createRoom(
roomId: widget.meetingId,
token: widget.token,
displayName: "John Doe",
micEnabled: micEnabled,
camEnabled: camEnabled,
defaultCameraIndex: kIsWeb
? 0
: 1 // Index of MediaDevices will be used to set default camera
);
setMeetingEventListener();
// Join room
_room.join();
super.initState();
}
Future<void> sendnotification(String api, callerId, status, roomId) async {
await sendCallStatus(
serverUrl: api, callerId: callerId, status: status, roomId: roomId);
}
void setState(fn) {
if (mounted) {
super.setState(fn);
}
}
// listening to meeting events
void setMeetingEventListener() {
_room.on(Events.roomJoined, () {
setState(() {
participants.putIfAbsent(
_room.localParticipant.id, () => _room.localParticipant);
});
});
_room.on(
Events.participantJoined,
(Participant participant) {
setState(
() => participants.putIfAbsent(participant.id, () => participant),
);
},
);
_room.on(Events.participantLeft, (String participantId) {
if (participants.containsKey(participantId)) {
setState(
() => participants.remove(participantId),
);
}
});
_room.on(Events.roomLeft, () {
participants.clear();
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => Home(
callerID: widget.callerId,
)),
(route) => false, // Removes all previous routes
);
});
}
// onbackButton pressed leave the room
Future<bool> _onWillPop() async {
_room.leave();
return true;
}
Future<void> sendCallStatus({
required String serverUrl,
required String callerId,
required String status,
required String roomId,
}) async {
final url = Uri.parse('$serverUrl/send-call-status');
try {
// Request payload
final body = jsonEncode({
'callerId': callerId,
'status': status,
'roomId': roomId,
});
// Sending the POST request
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
},
body: body,
);
// Handling the response
if (response.statusCode == 200) {
print("Notification sent successfully: ${response.body}");
} else {
print("Failed to send notification: ${response.statusCode}");
}
} catch (e) {
print("Error sending call status: $e");
}
}
// This widget is the root of your application.
Widget build(BuildContext context) {
// ignore: deprecated_member_use
return WillPopScope(
onWillPop: () => _onWillPop(),
child: Scaffold(
appBar: AppBar(
title: const Text('VideoSDK QuickStart'),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Text(widget.meetingId),
//render all participant
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
mainAxisExtent: 300,
),
itemBuilder: (context, index) {
return ParticipantTile(
key: Key(participants.values.elementAt(index).id),
participant: participants.values.elementAt(index));
},
itemCount: participants.length,
),
),
),
MeetingControls(
micEnabled: micEnabled,
camEnabled: camEnabled,
onToggleMicButtonPressed: () {
setState(() {
micEnabled = !micEnabled;
});
micEnabled ? _room.unmuteMic() : _room.muteMic();
},
onToggleCameraButtonPressed: () {
setState(() {
camEnabled = !camEnabled;
});
camEnabled ? _room.enableCam() : _room.disableCam();
},
onLeaveButtonPressed: () {
_room.leave();
},
),
],
),
),
),
);
}
}
Great, we have successfully added our videosdk meeting screens. Lets deep dive into main.dart file and home.dart file.
main.dart
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:videosdk_flutter_example/home.dart';
import 'package:videosdk_flutter_example/meeting/meeting_screen.dart';
String? videoSdkKey = dotenv.env["VIDEO_SDK_KEY"];
String? url = dotenv.env["SERVER_URL"];
('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
await dotenv.load(fileName: ".env");
runApp(const MyApp());
const platform = MethodChannel('com.yourapp/call');
platform.setMethodCallHandler((call) async {
if (call.method == "incomingCall") {
final data = call.arguments as Map;
final roomId = data["roomId"];
final callerId = data["callerId"];
}
});
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: '/',
onGenerateRoute: (settings) {
final uri = Uri.parse(settings.name ?? '');
if (uri.path == '/meeting') {
final roomId = uri.queryParameters['roomId'];
final callerId = uri.queryParameters['callerId'];
print("Callaer id in Main.dart file: $callerId");
return MaterialPageRoute(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => MeetingScreen(
meetingId: roomId!,
token: videoSdkKey!,
callerId: callerId!,
url: url!,
source: "true",
),
),
(route) => false, // Removes all previous routes
);
});
return const SizedBox(); // Placeholder widget (not displayed)
},
);
} else if (uri.path == '/home') {
final callerId = uri.queryParameters['callerId'];
return MaterialPageRoute(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => Home(
callerID: callerId!,
source: "true",
),
),
(route) => false, // Removes all previous routes
);
});
return const SizedBox(); // Placeholder widget (not displayed)
},
);
} else {
return MaterialPageRoute(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => Home(),
),
(route) => false, // Removes all previous routes
);
});
return const SizedBox(); // Placeholder widget (not displayed)
},
);
}
},
debugShowCheckedModeBanner: false,
);
}
}
After creating the main.dart file, now its time to create a home.dart file. In home.dart file we will be working on managing the notification and also the method call which are required.
Future<void> sendNotification({
required String callerId,
required String callerInfo,
required String roomId,
required String token,
}) async {
final Map<String, dynamic> payload = {
'callerId': callerId,
'callerInfo': {'id': callerInfo},
'videoSDKInfo': {'roomId': roomId, 'token': token},
};
try {
// Send POST request to the API
final response = await http.post(
Uri.parse("${apiUrl!}/send-notification"),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
);
// Handle the response from the API
if (response.statusCode == 200) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Message sent successfully")),
);
}
} else {
print('Failed to send notification: ${response.body}');
}
} catch (e) {
print('Error occurred while sending notification: $e');
}
}
Future makeCall(String callerID) async {
callerId = callerID;
if (await Permission.phone.isDenied) {
await Permission.phone.request();
}
if (await Permission.phone.isGranted) {
try {
final result = await platform
.invokeMethod('handleIncomingCall', {'callerId': callerID});
} catch (e) {
print('Error: $e');
}
} else {
print('Phone permission is not granted');
}
}
To get entire code for home.dart file you can refer here.
Deep Linking in Flutter for Android: Seamlessly Transitioning from Native to Flutter
So basically the concept of Deep linking states that the onclick event has to redirect to the specified app without any user interaction.
How It Works
- Native Call Trigger:
When a call is initiated while the app is in the background, the native side of the Android app detects the event. This setup ensures that the native platform efficiently handles the call logic. - Handling Call Events:
Upon user interaction (either answering or rejecting the call), the app seamlessly integrates with Flutter. The native side triggers a deep link back into the app, directing the user to a specific Flutter screen depending on the action. - Deep Link Transition:
The deep link opens the required Flutter screen, ensuring that the app behaves as expected, even after transitioning from the background state.
Key Implementation
- Defining the Deep Link:
I used a URI scheme to define the deep link format. For example:myapp://call?action=answered
ormyapp://call?action=rejected
.You can refer the code in CallConnectionService.kt - Native Side Configuration:
The native Android code listens for call events and usesIntent
to communicate with the Flutter engine. TheIntent
carries the deep link information to Flutter. - Flutter Integration:
Ge the Deep link and based on the routes redirect it to a particular page which is mention in the link, if call is answered then redirect the user to meeting and if rejected the open the app and send the caller that receiver and rejected the call.
App Reference
Conclusion
With this, we've successfully built the Flutter Android video calling app using the Telecom framework, VideoSDK, and Firebase. For additional features like chat messaging and screen sharing, feel free to refer to our documentation. If you encounter any issues with the implementation, don’t hesitate to reach out to us through our Discord community.