In a world where connectivity through audio and video calls is essential, if you're planning to create a video-calling app with call-trigger functionality, you've come to the right place.

In this tutorial, we will build a comprehensive video calling app for Android that enables smooth call handling and high-quality video communication. We’ll utilize the Telecom Framework to manage call functionality, Firebase for real-time data synchronization, and VideoSDK to deliver clear, reliable video conferencing.

Take a moment to watch the video demonstration and review the complete code for the sample app to see exactly what you'll be building in this blog.

Understanding the Telecom Framework

Before we get started, let’s take a closer look at the Telecom Framework. This framework manages both audio and video calls on Android devices, supporting traditional SIM-based calls as well as VoIP calls via the ConnectionService API.
The major components that Telecom manages are ConnectionService and InCallService:

  • ConnectionService handles the technical aspects of call connections, managing states, and audio/video routing.
  • InCallService manages the user interface, allowing users to see and interact with ongoing calls.

Understanding how the app will function internally before building it will make the development process smoother.

App Functionality Overview

To understand how the app functions, consider the following scenario: John wants to call his friend Max. John opens the app, enters Max's caller ID, and presses "Call." Max sees an incoming call UI on his device, with options to accept or reject the call. If he accepts, a video call is established between them using VideoSDK.

Steps in the Process:

  1. User Action: John enters Max's Caller ID and initiates the call.
  2. Database and Notification: The app maps the ID in the Firebase database and sends a notification to Max's device.
  3. Incoming Call UI: Max’s device receives the notification, triggering the incoming call UI using the Telecom Framework.
  4. Call Connection: If Max accepts, the video call begins using VideoSDK.

Here is a pictorial representation of the flow for better understanding.

Video SDK Image

Now that we have established the flow of the app and how it functions, let's get started with development!

Core Functionality of the App

The app relies on several key libraries to manage video calling and notifications:

  • Android Telecom Framework: Manages call routing and interaction with the system UI for incoming and outgoing calls.
  • Retrofit: Used for sending and receiving API requests, including call initiation and status updates.
  • Firebase Cloud Messaging (FCM): Handles push notifications to trigger call events.
  • Firebase Realtime Database: Stores user tokens and caller IDs for establishing video calls.
  • VideoSDK: Manages the actual video conferencing features.

Prerequisites

  • Android Studio (for Android app development)
  • Firebase Project (for notifications and database management)
  • VideoSDK Account (for video conferencing functionality)
  • Node.js and Firebase Tools (for backend development)

Make sure you have a basic understanding of Android development, Retrofit, and Firebase Cloud Messaging.

Now that we've covered the prerequisites, let's dive into building the app.

Android App Setup

Add Dependencies

  1. In yoursettings.gradle, add Jetpack and Maven repositories:
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url 'https://www.jitpack.io' }
        maven { url "https://maven.aliyun.com/repository/jcenter" }
    }
}
  1. In your build.gradle file, add the following dependencies:
   //VideoSdk
    implementation 'live.videosdk:rtc-android-sdk:0.1.35'
    implementation 'com.nabinbhandari.android:permissions:3.8'
    implementation 'com.amitshekhar.android:android-networking:1.0.2'

    //Firebase
    implementation 'com.google.firebase:firebase-messaging:23.0.0'
    implementation platform('com.google.firebase:firebase-bom:33.4.0')
    implementation 'com.google.firebase:firebase-analytics'

    //Retrofit
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'

Set Permissions in AndroidManifest.xml

Ensure the following permissions are configured:

    <uses-feature
        android:name="android.hardware.camera"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.telephony"
        android:required="false" />

    <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />

How to Configure Firebase for Notifications and Realtime Database

[a] Firebase Setup for Notifications & Realtime Database

Step 1: Add Firebase to Your Android App

  • Go to the Firebase Console and create a new project.
  • Download the google-services.json file and place it in your project’s app/ directory

Step 2: Add Firebase Dependencies

In your build.gradle files, add the necessary Firebase dependencies:

// Project-level build.gradle
classpath 'com.google.gms:google-services:4.3.10'

// App-level build.gradle
apply plugin: 'com.google.gms.google-services'

implementation 'com.google.firebase:firebase-messaging:23.0.0'
implementation 'com.google.firebase:firebase-database:20.0.0'

Step 3: Enable Firebase Messaging and Real-time Database

  • Enable Firebase Cloud Messaging (FCM) and Realtime Database in the Firebase console under your project settings.

Step 4: Firebase Service Configuration

Ensure your app is registered in Firebase, and implement a FirebaseMessagingService to handle notifications, which we will do later.

[b] Firebase Server-Side Setup (serviceAccount.json file)

To set up Firebase Admin SDK for your server, follow these steps:

  1. Go to the Firebase Console and select your project.
  2. In Project Settings, navigate to the Service Accounts tab.
  3. Click on Generate a new private key to download your service account JSON file. This file will be named something like <your-project>-firebase-adminsdk-<unique-id>.json.

Project Structure

Root/
├── app/
│   ├── src/
│   │   ├── main/
│   │   │   ├── java/
│   │   │   │   ├── FirebaseDatabase/
│   │   │   │   │   └── DatabaseUtils.kt
│   │   │   │   ├── Meeting/
│   │   │   │   │   ├── MeetingActivity.kt
│   │   │   │   │   └── ParticipantAdapter.kt
│   │   │   │   ├── Network/
│   │   │   │   │   ├── ApiClient.kt
│   │   │   │   │   ├── ApiService.kt
│   │   │   │   │   ├── NetworkCallhandler.kt
│   │   │   │   │   └── NetworkUtils.kt
│   │   │   │   ├── Services/
│   │   │   │   │   ├── CallConnectionService.kt
│   │   │   │   │   ├── MyFirebaseMessagingService.kt
│   │   │   │   │   └── MyInCallService.kt
│   │   │   │   ├── MainActivity.kt
│   │   │   │   ├── MainApplication.kt
│   │   │   │   └── MeetingIdCallBack.kt
│   │   ├── res/
│   │   │   └── layout/
│   │   │       ├── activity_main.xml
│   │   │       ├── activity_meeting.xml
│   │   │       └── item_remote_peer.xml
├── build.gradle
└── settings.gradle

Let's get started with the basic UI of the call-initiating screen.

UI Development

The MainActivity layout provides the initial screen where users can initiate video calls. The key components include:

  • Caller ID Input: An EditText for entering the unique caller ID to initiate a call.
  • Call Button: A button to trigger the call.
  • Unique ID Display: Displays a unique ID for each user.
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:background="?android:attr/windowBackground">

    <com.google.android.material.appbar.MaterialToolbar
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        app:title="VideoSDK CallKit Example"
        app:titleTextColor="@color/white"
        android:background="?attr/colorPrimaryDark"/>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:orientation="vertical"
        android:gravity="center"
        android:padding="24dp"
        android:layout_weight="1">

   <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:drawable/editbox_background_normal"
        android:orientation="vertical"
        android:layout_marginBottom="50dp"
        android:backgroundTint="#E7E1E1">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Your Caller ID"
            android:textSize="23sp"
            android:textColor="@color/black"
            android:paddingLeft="12dp"
            android:paddingRight="12dp"
            android:fontFamily="sans-serif-medium"
            android:gravity="center"
            android:elevation="4dp"
            android:layout_margin="8dp"
            android:clipToOutline="true"
            />

        <View
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:background="@color/black"
            />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:gravity="center"
            >
            <TextView
                android:id="@+id/txt_callId"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Call Id"
                android:textSize="32sp"
                android:textColor="@color/black"
                android:paddingLeft="12dp"
                android:paddingRight="12dp"
                android:fontFamily="sans-serif-medium"
                android:gravity="center"
                android:layout_margin="8dp"
                android:textIsSelectable="true"
                android:clipToOutline="true"
                />

            <ImageView
                android:id="@+id/copyIcon"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:src="@drawable/baseline_content_copy_24"
                android:padding="8dp"
                />
        </LinearLayout>
    </LinearLayout>


    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:drawable/editbox_background_normal"
        android:orientation="vertical"
        android:gravity="center"
        android:backgroundTint="#E7E1E1"
        android:padding="14dp"
        >
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Enter call ID of user you want to call"
            android:textSize="23sp"
            android:textColor="@color/black"
            android:fontFamily="sans-serif-medium"
            android:elevation="4dp"
            android:layout_marginBottom="20dp"
            />

        <EditText
            android:inputType="number"
            android:id="@+id/caller_id_input"
            android:layout_width="match_parent"
            android:layout_height="64dp"
            android:hint="Enter Caller ID"
            android:layout_marginBottom="10dp"
            android:background="@android:drawable/editbox_background_normal"
            android:textSize="18sp"
            android:textColor="@android:color/black"
            android:gravity="center"/>

        <Button
            android:id="@+id/call_button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Call"
            android:padding="16dp"
            android:layout_marginTop="20dp"
            android:textSize="18sp"
            android:textColor="@android:color/white"
            />

    </LinearLayout>

    </LinearLayout>
</LinearLayout>

activity_main.xml

This is how the UI will look:

Video SDK Image

With the UI for calling in place let's start with the actual calling development.

Firebase Messaging for Call Initiation

  1. To initiate the call process, we first need to secure user permission to manage notifications and calls.
class MainActivity : AppCompatActivity() {
    private lateinit var callerIdInput: EditText
    private lateinit var myId: TextView
    private lateinit var copyIcon: ImageView
    private var myCallId: String = (10000000 + Random().nextInt(90000000)).toString()
    private lateinit var FcmToken: String

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        checkSelfPermission(REQUESTED_PERMISSIONS[0], PERMISSION_REQ_ID)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            checkSelfPermission(REQUESTED_PERMISSIONS[1], PERMISSION_REQ_ID)
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == 133) {
            var allPermissionsGranted = true
            for (result in grantResults) {
                if (result != PackageManager.PERMISSION_GRANTED) {
                    allPermissionsGranted = false
                    break
                }
            }

            if (allPermissionsGranted) {
                registerPhoneAccount()
            } else {
                Toast.makeText(this, "Permissions are required for call management", Toast.LENGTH_LONG).show()
            }
        }
    }

    private fun checkSelfPermission(permission: String, requestCode: Int): Boolean {
        if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED)
        {
            ActivityCompat.requestPermissions(this, REQUESTED_PERMISSIONS, requestCode)
            return false
        }
        return true
    }

    companion object {
        private const val PERMISSION_REQ_ID = 22

        private val REQUESTED_PERMISSIONS = arrayOf(
            Manifest.permission.READ_PHONE_STATE,
            Manifest.permission.POST_NOTIFICATIONS
        )
    }
}
  1. Once permission is granted, the next step is to identify each user and retrieve their messaging token, enabling us to send notifications effectively.
Don't worry if you encounter an error due to a missing file. Please continue following the steps, as the required file will be provided later in the guide.
class MainActivity : AppCompatActivity() {
    private lateinit var callerIdInput: EditText
    private lateinit var myId: TextView
    private lateinit var copyIcon: ImageView
    private var myCallId: String = (10000000 + Random().nextInt(90000000)).toString()
    private lateinit var FcmToken: String

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        myId = findViewById(R.id.txt_callId)
        callerIdInput = findViewById(R.id.caller_id_input)

        copyIcon = findViewById(R.id.copyIcon)
        val callButton = findViewById<Button>(R.id.call_button)
        myId.text = myCallId


        val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
        val clipData = ClipData.newPlainText("copied text", myCallId)

        copyIcon.setOnClickListener {
            clipboardManager.setPrimaryClip(clipData)
            Toast.makeText(this, "Copied to clipboard", Toast.LENGTH_SHORT).show()
        }

        NetworkCallHandler.myCallId = myCallId
        DatabaseUtils.myCallId = myCallId


        NetworkUtils().createMeeting(object : MeetingIdCallBack {
            override fun onMeetingIdReceived(meetingId: String, token: String) {
                MainApplication.meetingId=meetingId

            }
        })
        //Firebase Notification
        val channel = NotificationChannel("notification_channel", "notification_channel",
            NotificationManager.IMPORTANCE_DEFAULT
        )
        val manager = getSystemService(NotificationManager::class.java)
        manager.createNotificationChannel(channel)
        FirebaseMessaging.getInstance().subscribeToTopic("general")
            .addOnCompleteListener { task ->
                var msg = "Subscribed Successfully"
                if (!task.isSuccessful) {
                    msg = "Subscription failed"
                }
                Toast.makeText(this@MainActivity, msg, Toast.LENGTH_SHORT).show()
            }

        //Firebase Database Actions
        val databaseUtils = DatabaseUtils()
        val databaseReference = FirebaseDatabase.getInstance().reference
        FirebaseMessaging.getInstance().token.addOnCompleteListener { task: Task<String> ->
            FcmToken = task.result
            NetworkCallHandler.FcmToken = task.result
            DatabaseUtils.FcmToken = task.result
            databaseUtils.sendUserDataToFirebase(databaseReference)
        }

        //telecom Api
        registerPhoneAccount()

        callButton.setOnClickListener {
            val callerNumber = callerIdInput.text.toString()
            if (callerNumber.length == 8) {
                databaseUtils.retrieveUserData(databaseReference, callerNumber)
            } else {
                Toast.makeText(
                    this@MainActivity,
                    "Please input the correct caller ID",
                    Toast.LENGTH_SHORT
                ).show()
            }
        }
    }
    
    private fun registerPhoneAccount() {
        val phoneAccount = PhoneAccount.builder(MainApplication.phoneAccountHandle, "VideoSDK")
            .setCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER)
            .build()

        MainApplication.telecomManager?.registerPhoneAccount(phoneAccount)


        if (ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.READ_PHONE_STATE
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        var checkAccount = 0
        val list: List<PhoneAccountHandle> =
            MainApplication.telecomManager!!.callCapablePhoneAccounts
        for (handle in list) {
            if (handle.componentName.className == "live.videosdk.ConnectionService.quickstart.Services.CallConnectionService") {
                checkAccount++
                break
            }
        }
        if (checkAccount == 0) {
            val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS)
            startActivity(intent)
        }
    }
}

Check if the FirebaseMessaging token is already present in the database. If it exists, update the callee ID; otherwise, create a new entry in the database.

class DatabaseUtils {
    companion object{
    lateinit var myCallId: String
    lateinit var FcmToken: String
}
 private lateinit var calleeInfoToken : String

    val networkUtils: NetworkCallHandler = NetworkCallHandler()
    fun sendUserDataToFirebase(databaseReference: DatabaseReference) {
        val usersRef = databaseReference.child("User")

        usersRef.orderByChild("token").equalTo(FcmToken)
            .addListenerForSingleValueEvent(object : ValueEventListener {
                override fun onDataChange(dataSnapshot: DataSnapshot) {
                    if (dataSnapshot.exists()) {
                        // Token exists, update the callerId
                        for (userSnapshot in dataSnapshot.children) {
                            userSnapshot.ref.child("callerId").setValue(myCallId)
                                .addOnSuccessListener { aVoid: Void? ->
                                    Log.d("FirebaseData", "CallerId successfully updated.")
                                }
                                .addOnFailureListener { e: Exception? ->
                                    Log.e("FirebaseError", "Failed to update callerId.", e)
                                }
                        }
                    } else {
                        // Token doesn't exist, create new entry
                        val userId = usersRef.push().key
                        val map: MutableMap<String, Any?> = HashMap()
                        map["callerId"] = myCallId
                        map["token"] = FcmToken
                        
                        if (userId != null) {
                            usersRef.child(userId).setValue(map)
                                .addOnSuccessListener { aVoid: Void? ->
                                    Log.d("FirebaseData", "Data successfully saved.")
                                }
                                .addOnFailureListener { e: Exception? ->
                                    Log.e("FirebaseError", "Failed to save data.", e)
                                }
                        }
                    }
                }

                override fun onCancelled(databaseError: DatabaseError) {
                    Log.e(
                        "FirebaseError",
                        "Error checking for existing token",
                        databaseError.toException()
                    )
                }
            })
    }
}

When the call is initiated, first verify if the caller ID exists in the Firebase database. If it does, proceed to invoke the notification method.

class DatabaseUtils {

    fun retrieveUserData(databaseReference: DatabaseReference, callerNumber: String) {
        databaseReference.child("User").orderByChild("callerId").equalTo(callerNumber)
            .addListenerForSingleValueEvent(object : ValueEventListener {
                override fun onDataChange(snapshot: DataSnapshot) {
                    if (snapshot.exists()) {
                        for (data in snapshot.children) {
                            val token = data.child("token").getValue(
                                String::class.java
                            )
                            if (token != null) {
                                calleeInfoToken = token
                                NetworkCallHandler.calleeInfoToken = token
                                networkUtils.initiateCall()
                                break
                            }
                        }
                    } else {
                        Log.d("TAG", "retrieveUserData: No matching callerId found")
                    }
                }

                override fun onCancelled(error: DatabaseError) {
                    Log.e("FirebaseError", "Failed to read data from Firebase", error.toException())
                }
            })
    }
}


We'll configure the VideoSDK token and Meeting ID as soon as the home screen loads, ensuring they're ready when the user initiates a call.

class NetworkUtils {
    //Replace with the token you generated from the VideoSDK Dashboard
    var sampleToken: String = MainApplication.token
    fun createMeeting(callBack: MeetingIdCallBack) {

        AndroidNetworking.post("https://api.videosdk.live/v2/rooms")
            .addHeaders("Authorization", sampleToken) //we will pass the token in the Headers
            .build()
            .getAsJSONObject(object : JSONObjectRequestListener {
                override fun onResponse(response: JSONObject) {
                    try {
                        // response will contain `roomId`
                        val meetingId = response.getString("roomId")
                        callBack.onMeetingIdReceived(meetingId,sampleToken)
                        //
                    } catch (e: JSONException) {
                        e.printStackTrace()
                        Log.d("TAG", "onResponse: $e")
                    }
                }

                override fun onError(anError: ANError) {
                    anError.printStackTrace()
                    Log.d("TAG", "onError: ")
                }
            })
    }
}

The MeetingIdCallBack the interface allows us to receive the meetingId and token in our MainActivity

interface MeetingIdCallBack {
    fun onMeetingIdReceived(meetingId: String, token: String)
}

using onMeetingIdReceived callback method to get meetingId and token.

class MainActivity : AppCompatActivity() {

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

            NetworkUtils().createMeeting(object : MeetingIdCallBack {
            override fun onMeetingIdReceived(meetingId: String, token: String) {
                MainApplication.meetingId=meetingId

            }
        })
    }
}
  1. The next step is to initiate the call.

For this, we’ll set up an Express server with two APIs as Firebase functions—one to trigger notifications on the other device and another to update the call status (accepted or rejected).

Start by importing and initializing the required packages in  server.js .

we will also need to initialize Firebase Admin SDK.

const functions = require("firebase-functions");
const express = require("express");
const cors = require("cors");
const morgan = require("morgan");
var admin = require("firebase-admin");
const { v4: uuidv4 } = require("uuid");

const app = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan("dev"));

// Path to your service account key file for Firebase Admin SDK
var serviceAccount = require("add_path_here");

// Initialize Firebase Admin SDK
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: "database_url" // Replace with your database URL
});

// Home Route
app.get("/", (req, res) => {
  res.send("Hello World!");
});

// Start the Express server
app.listen(9000, () => {
  console.log(`API server listening at http://localhost:9000`);
});

// Export app as a Firebase Cloud Function
exports.app = functions.https.onRequest(app);
  • The first API we need is initiate-call, which will be used to send a notification to the receiving user and start the call by sending details like caller information and VideoSDK room details.
// Initiate call notification (for Android)
app.post("/initiate-call", (req, res) => {
  const { calleeInfo, callerInfo, videoSDKInfo } = req.body;

  var FCMtoken = calleeInfo.token;
    const info = JSON.stringify({
      callerInfo,
      videoSDKInfo,
      type: "CALL_INITIATED",
    });
    var message = {
      data: {
        info,
      },
      android: {
        priority: "high",
      },
      token: FCMtoken,
    };
    
  // Send the FCM message using firebase-admin
  admin.messaging().send(message)
    .then((response) => {
      console.log("Successfully sent FCM message:", response);
      res.status(200).send(response);
    })
    .catch((error) => {
      console.log("Error sending FCM message:", error);
      res.status(400).send("Error sending FCM message: " + error);
    });
});
  • The second API we need is update-call, which updates the status of the incoming call (such as accepted, rejected, etc.) and sends a notification to the caller.
// Update call notification (for Android)
app.post("/update-call", (req, res) => {
  const { callerInfo, type } = req.body;
  const info = JSON.stringify({
    callerInfo,
    type,
  });

  var message = {
    data: {
      info,
    },
    token: callerInfo.token,
  };
  var message = {
    data: { info },
    token: callerInfo.token, // Token for the target device
    android: {
      priority: "high",
      notification: {
        title: "Call Updated",
        body: "Your call has been updated by " + callerInfo.name,
      },
    },
  };

  // Send the update message through firebase-admin
  admin.messaging().send(message)
    .then((response) => {
      console.log("Successfully updated call:", response);
      res.status(200).send(response);
    })
    .catch((error) => {
      console.log("Error updating call:", error);
      res.status(400).send("Error updating call: " + error);
    });
});

4. Now that the APIs are created, we will trigger them from the app.

Here the FCM_SERVER_URL needs to be updated with the URL of your Firebase functions.

You can either deploy the server or run the server in a local environment using npm run server.js

If you're running it on a local device, you need to use the device's IP address.

object ApiClient {
// Base URL for the API endpoint, replace "YOUR_BASE_URL" with the actual URL. e.g http://172.20.10.6:9000/
    private const val BASE_URL = "YOUR_BASE_URL" 
    private var retrofit: Retrofit? = null

    val client: Retrofit?
        get() {
            if (retrofit == null) {
                retrofit = Retrofit.Builder()
                    .baseUrl(BASE_URL) // ScalarsConverterFactory handles plain text responses
                    .addConverterFactory(ScalarsConverterFactory.create()) // GsonConverterFactory handles JSON responses
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
            }
            return retrofit
        }
}

Now, let's define endpoints for API Calls.

interface ApiService {

    @POST("/initiate-call")
    @JvmSuppressWildcards
    fun initiateCall(@Body callRequestBody: Map<String, Any>): Call<String>

    @POST("/update-call")
    @JvmSuppressWildcards
    fun updateCall(@Body callUpdateBody: Map<String, Any>): Call<String>
}

Initiates a call by sending caller, callee, and VideoSDK information to the server
and handles the server response and logs success or failure accordingly.

class NetworkCallHandler  {

    fun initiateCall() {
        val apiService: ApiService = ApiClient.client!!.create(ApiService::class.java)

        val callerInfo: MutableMap<String, String> = HashMap()
        val calleeInfo: MutableMap<String, String> = HashMap()
        val videoSDKInfo: MutableMap<String, String> = HashMap()

        // callerInfo
        callerInfo["callerId"] = myCallId
        callerInfo["token"] = FcmToken
        // calleeInfo
        calleeInfo["token"] = calleeInfoToken
        // videoSDKInfo
        videoSDKInfo["meetingId"] = MainApplication.meetingId ?: return
        videoSDKInfo["token"] = MainApplication.token

        val callRequestBody: MutableMap<String, Any> = HashMap()
        callRequestBody["callerInfo"] = callerInfo
        callRequestBody["calleeInfo"] = calleeInfo
        callRequestBody["videoSDKInfo"] = videoSDKInfo

        val call: Call<String> = apiService.initiateCall(callRequestBody)
        call.enqueue(object : Callback<String> {
            override fun onResponse(call: Call<String>, response: Response<String>) {
                if (response.isSuccessful) {
                    Log.d("API", "Call initiated: " + response.body())
                } else {
                    Log.e("API", "Failed to initiate call: " + response.message())
                }
            }

            override fun onFailure(call: Call<String>, t: Throwable) {
                Log.e("API", "API call failed: " + t.message)
            }
        })
    }
}

5. The notification sent is now configured. Now we need to invoke the call when you receive the notification.

You can extract all the information from the notification body, which will help us to create a meeting.

class MyFirebaseMessagingService : FirebaseMessagingService() {

    companion object {
        private const val TAG = "FCMService"
        private const val CHANNEL_ID = "notification_channel"
        lateinit var FCMtoken: String
    }

    private var callerID: String? = null
    private var meetingId: String? = null
    private var token: String? = null

    override fun onNewToken(token: String) {
        super.onNewToken(token)
    }

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val data = remoteMessage.data

        if (data.isNotEmpty()) {
            try {
                val `object` = JSONObject(data["info"]!!)

                val callerInfo = `object`.getJSONObject("callerInfo")
                callerID = callerInfo.getString("callerId")
                FCMtoken = callerInfo.getString("token")

                if (`object`.has("videoSDKInfo")) {
                    val videoSdkInfo = `object`.getJSONObject("videoSDKInfo")
                    meetingId = videoSdkInfo.getString("meetingId")
                    token = videoSdkInfo.getString("token")
                    handleIncomingCall(callerID)
                }

                val type = `object`.getString("type")
                when (type) {
                    "ACCEPTED" -> startMeeting()
                    "REJECTED" -> {
                        showIncomingCallNotification(callerID)
                        Handler(Looper.getMainLooper()).post {
                            Toast.makeText(applicationContext, "CALL REJECTED FROM CALLER ID: $callerID", Toast.LENGTH_SHORT).show()
                        }
                    }
                }

            } catch (e: JSONException) {
                throw RuntimeException(e)
            }
        } else {
            Log.d(TAG, "onMessageReceived: No data found in the notification payload.")
        }
    }

    private fun startMeeting() {
        val intent = Intent(applicationContext, MeetingActivity::class.java).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            putExtra("meetingId", MainApplication.meetingId)
            putExtra("token", MainApplication.token)
        }
        startActivity(intent)
    }

    private fun handleIncomingCall(callerId: String?) {
        val extras = Bundle().apply {
            val uri = Uri.fromParts("tel", callerId, null)
            putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, uri)
            putString("meetingId", meetingId)
            putString("token", token)
            putString("callerID", callerId)
        }

        try {
            MainApplication.telecomManager?.addNewIncomingCall(MainApplication.phoneAccountHandle, extras)
        } catch (cause: Throwable) {
            Log.e("handleIncomingCall", "error in addNewIncomingCall", cause)
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun showIncomingCallNotification(callerId: String?) {
        createNotificationChannel()

        val intent = Intent(this, MainActivity::class.java)
        val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_MUTABLE)

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.baseline_call_24)
            .setContentTitle("Call REJECTED")
            .setContentText("Call from $callerId")
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setFullScreenIntent(pendingIntent, true)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setCategory(NotificationCompat.CATEGORY_CALL)
            .build()

        val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.notify(1, notification)
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun createNotificationChannel() {
        val channel = NotificationChannel(CHANNEL_ID, "Incoming Calls", NotificationManager.IMPORTANCE_HIGH)
        val notificationManager = getSystemService(NotificationManager::class.java)
        notificationManager.createNotificationChannel(channel)
    }
}

Add this MyFirebaseMessagingService to AndroidManifest.xml

<service
  android:name=".Services.MyFirebaseMessagingService"
  android:enabled="true"
  android:exported="true"
  android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
  <intent-filter>
      <action android:name="com.google.firebase.MESSAGING_EVENT" />
      <action android:name="com.google.android.c2dm.intent.RECEIVE" />
  </intent-filter>
</service>

Now when the notification is received, this should trigger a call.

To achieve this, we need to register TelecomManager with a PhoneAccountHandle and CallConnectionService to manage and handle calls.

Initialize the TelecomManager and PhoneAccountHandle in the MainApplication class to make them accessible throughout the application.

Also, to initiate the call with the VideoSDK, you need to add the VideoSDK token to the main application. You can obtain this token from the VideoSDK Dashboard.

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        VideoSDK.initialize(applicationContext)

        telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
        val componentName: ComponentName = ComponentName(this, CallConnectionService::class.java)
        phoneAccountHandle = PhoneAccountHandle(componentName, "myAccountId")
    }

    companion object {
        var telecomManager: TelecomManager? = null
        var phoneAccountHandle: PhoneAccountHandle? = null
        var meetingId: String? = null
        var token: String = "VideoSDK token"
    }
}

Now, In MainActivity we'll register the PhoneAccount and check for permission to manage Call Activity.

class MainActivity : AppCompatActivity() {
    private lateinit var callerIdInput: EditText
    private lateinit var myId: TextView
    private lateinit var copyIcon: ImageView
    private var myCallId: String = (10000000 + Random().nextInt(90000000)).toString()
    private lateinit var FcmToken: String

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //telecom Api
        registerPhoneAccount()
    }

    private fun registerPhoneAccount() {
        val phoneAccount = PhoneAccount.builder(MainApplication.phoneAccountHandle, "VideoSDK")
            .setCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER)
            .build()

        MainApplication.telecomManager?.registerPhoneAccount(phoneAccount)


        if (ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.READ_PHONE_STATE
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            return
        }
        var checkAccount = 0
        val list: List<PhoneAccountHandle> =
            MainApplication.telecomManager!!.callCapablePhoneAccounts
        for (handle in list) {
            if (handle.componentName.className == "live.videosdk.ConnectionService.quickstart.Services.CallConnectionService") {
                checkAccount++
                break
            }
        }
        if (checkAccount == 0) {
            val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS)
            startActivity(intent)
        }
    }
}

Next, to manage VoIP calls, we’ll create a new service CallConnectionService that extends ConnectionService. This service will handle the technical aspects of call connections, such as managing call states and routing audio/video.

class CallConnectionService : ConnectionService() {
    var callerID: String? = null
    val obj = NetworkCallHandler()

    override fun onCreateIncomingConnection(
        connectionManagerPhoneAccount: PhoneAccountHandle,
        request: ConnectionRequest
    ): Connection {
        // Create a connection for the incoming call
        val connection: Connection = object : Connection() {
            override fun onAnswer() {
                super.onAnswer()
                //getting videosdk info
                val extras = request.extras
                val meetingId = extras.getString("meetingId")
                val token = extras.getString("token")
                callerID = extras.getString("callerID")
                obj.updateCall("ACCEPTED")
                // Start the meeting activity with the extracted data
                val intent = Intent(
                    applicationContext,
                    MeetingActivity::class.java
                )
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                intent.putExtra("meetingId", meetingId)
                intent.putExtra("token", token)
                startActivity(intent)


                //update
                setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
                destroy()
            }

            override fun onReject() {
                super.onReject()
                val intent = Intent(
                    applicationContext,
                    MainActivity::class.java
                )
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                startActivity(intent)
                //update
                obj.updateCall("REJECTED")
                setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
                destroy()
            }
        }

        // Set call address
        connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
        connection.setCallerDisplayName(callerID, TelecomManager.PRESENTATION_ALLOWED)
        connection.setInitializing() // Indicates that the call is being set up
        connection.setActive() // Activate the call

        return connection
    }

    override fun onCreateOutgoingConnection(
        connectionManagerPhoneAccount: PhoneAccountHandle,
        request: ConnectionRequest
    ): Connection {
        // Create a connection for the outgoing call
        val connection: Connection = object : Connection() {}
        connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
        connection.setActive()
    }
}

Add the service to the AndroidManifest.xml

<service
    android:name=".Services.CallConnectionService"
    android:enabled="true"
    android:exported="true"
    android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
    <intent-filter>
        <action android:name="android.telecom.ConnectionService" />
    </intent-filter>
</service>

Now, To display the default Android call UI, we'll create another service called MyInCallService.

package live.videosdk.connectionservice.connectionservice_quickstart.Services

import android.Manifest
import android.content.pm.PackageManager
import android.telecom.Call
import android.telecom.InCallService
import android.telecom.TelecomManager
import androidx.core.app.ActivityCompat

class MyInCallService : InCallService() {
    override fun onCallAdded(call: Call) {
        super.onCallAdded(call)
        call.registerCallback(object : Call.Callback() {
            override fun onStateChanged(call: Call, state: Int) {
                super.onStateChanged(call, state)
                if (state == Call.STATE_ACTIVE) {
                    // Handle the active call state
                }
            }
        })
        // Bring up the default UI for managing the call
        setUpDefaultCallUI(call)
    }

    override fun onCallRemoved(call: Call) {
        super.onCallRemoved(call)
        // Clean up call-related resources
    }

    private fun setUpDefaultCallUI(call: Call) {
        // Start the default in-call UI
        val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
        if (telecomManager != null) {
            if (ActivityCompat.checkSelfPermission(
                    this,
                    Manifest.permission.READ_PHONE_STATE
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                return
            }
            telecomManager.showInCallScreen(true)
        }
    }
}

Add the service to AndroidMenifest.xmlThe first

<service
  android:name=".Services.MyInCallService"
  android:exported="true"
  android:permission="android.permission.BIND_INCALL_SERVICE">
  <intent-filter>
      <action android:name="android.telecom.InCallService" />
  </intent-filter>
</service>

Wow!! You just implemented the calling feature, which works like a charm.
Video SDK Image

VideoSDK Integration for VideoCall

  1. The first step in integrating the VideoSDK is to initialize VideoSDK.

MainApplication.kt

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        VideoSDK.initialize(applicationContext)
    }
}

When the call is received, the intent is used to transition from the call screen to the meeting screen, passing the meetingId and videoSDK token along with it.

class CallConnectionService : ConnectionService() {
    
    override fun onCreateIncomingConnection(
        connectionManagerPhoneAccount: PhoneAccountHandle,
        request: ConnectionRequest
    ): Connection {
        // Create a connection for the incoming call
        val connection: Connection = object : Connection() {
            override fun onAnswer() {
                super.onAnswer()
                
                val intent = Intent(
                    applicationContext,
                    MeetingActivity::class.java
                )
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                intent.putExtra("meetingId", meetingId)
                intent.putExtra("token", token)
                startActivity(intent)
            }
        }
            //...
    }
}

Next, we will add our MeetingView which will show the buttons and Participants View in the MeetingActivity.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    tools:context=".MeetingActivity">

    <TextView
        android:id="@+id/tvMeetingId"
        style="@style/TextAppearance.AppCompat.Display1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Meeting Id" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvParticipants"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <Button
            android:id="@+id/btnMic"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginVertical="8dp"
            android:text="Mic"/>

        <Button
            android:id="@+id/btnLeave"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginVertical="8dp"
            android:layout_marginHorizontal="8dp"
            android:text="Leave"/>

        <Button
            android:id="@+id/btnWebcam"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginVertical="8dp"
            android:text="Webcam" />

    </LinearLayout>


</LinearLayout>

Here is the logic for the MeetingActivity

class MeetingActivity : AppCompatActivity() {
    // declare the variables we will be using to handle the meeting
    private  var meeting: Meeting? = null
    private var micEnabled = true
    private var webcamEnabled = true

    private lateinit var rvParticipants: RecyclerView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_meeting)

        checkSelfPermission(REQUESTED_PERMISSIONS[0], PERMISSION_REQ_ID)
        checkSelfPermission(REQUESTED_PERMISSIONS[1], PERMISSION_REQ_ID)

        val token = intent.getStringExtra("token")
        val meetingId = intent.getStringExtra("meetingId")
        val participantName = "John Doe"

        // 1. Configuration VideoSDK with Token
        VideoSDK.config(token)
        // 2. Initialize VideoSDK Meeting
        meeting = VideoSDK.initMeeting(
            this@MeetingActivity, meetingId, participantName,
            micEnabled, webcamEnabled, null, null, true, null, null
        )

        // 3. Add event listener for listening upcoming events
        meeting!!.addEventListener(meetingEventListener)

        //4. Join VideoSDK Meeting
        meeting!!.join()

        (findViewById<View>(R.id.tvMeetingId) as TextView).text =
            meetingId

        // actions
        setActionListeners()

        rvParticipants = findViewById(R.id.rvParticipants)
        rvParticipants.setLayoutManager(GridLayoutManager(this, 2))
        rvParticipants.setAdapter(ParticipantAdapter(meeting!!))
    }

    // creating the MeetingEventListener
    private val meetingEventListener: MeetingEventListener = object : MeetingEventListener() {
        override fun onMeetingJoined() {
            Log.d("#meeting", "onMeetingJoined()")
        }

        override fun onMeetingLeft() {
            Log.d("#meeting", "onMeetingLeft()")
            meeting = null
            if (!isDestroyed) finish()
        }

        override fun onParticipantJoined(participant: Participant) {
            Toast.makeText(
                this@MeetingActivity,
                participant.displayName + " joined",
                Toast.LENGTH_SHORT
            ).show()
        }

        override fun onParticipantLeft(participant: Participant) {
            Toast.makeText(
                this@MeetingActivity,
                participant.displayName + " left",
                Toast.LENGTH_SHORT
            ).show()
        }
    }

    private fun setActionListeners() {
        // toggle mic
        findViewById<View>(R.id.btnMic).setOnClickListener { view: View? ->
            if (micEnabled) {
                // this will mute the local participant's mic
                meeting!!.muteMic()
                Toast.makeText(
                    this@MeetingActivity,
                    "Mic Disabled",
                    Toast.LENGTH_SHORT
                ).show()
            } else {
                // this will unmute the local participant's mic
                meeting!!.unmuteMic()
                Toast.makeText(
                    this@MeetingActivity,
                    "Mic Enabled",
                    Toast.LENGTH_SHORT
                ).show()
            }
            micEnabled = !micEnabled
        }

        // toggle webcam
        findViewById<View>(R.id.btnWebcam).setOnClickListener { view: View? ->
            if (webcamEnabled) {
                // this will disable the local participant webcam
                meeting!!.disableWebcam()
                Toast.makeText(
                    this@MeetingActivity,
                    "Webcam Disabled",
                    Toast.LENGTH_SHORT
                ).show()
            } else {
                // this will enable the local participant webcam
                meeting!!.enableWebcam()
                Toast.makeText(
                    this@MeetingActivity,
                    "Webcam Enabled",
                    Toast.LENGTH_SHORT
                ).show()
            }
            webcamEnabled = !webcamEnabled
        }

        // leave meeting
        findViewById<View>(R.id.btnLeave).setOnClickListener { view: View? ->
            // this will make the local participant leave the meeting
            meeting!!.leave()
        }
    }

    override fun onDestroy() {
        rvParticipants!!.adapter = null
        super.onDestroy()
    }

    private fun checkSelfPermission(permission: String, requestCode: Int): Boolean {
        if (ContextCompat.checkSelfPermission(this, permission) !=
            PackageManager.PERMISSION_GRANTED
        ) {
            ActivityCompat.requestPermissions(this, REQUESTED_PERMISSIONS, requestCode)
            return false
        }
        return true
    }

    companion object {
        private const val PERMISSION_REQ_ID = 22

        private val REQUESTED_PERMISSIONS = arrayOf(
            Manifest.permission.RECORD_AUDIO,
            Manifest.permission.CAMERA
        )
    }
}

Here, we display the participants in a RecyclerView. To implement this, you'll need to use the following RecyclerView Adapter components:

a. item_remote_peer : UI for each participant

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:background="@color/cardview_dark_background"
    tools:layout_height="200dp">

    <live.videosdk.rtc.android.VideoView
        android:id="@+id/participantView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="gone" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:background="#99000000"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tvName"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:padding="4dp"
            android:textColor="@color/white" />

    </LinearLayout>

</FrameLayout>

b. ParticipantAdapter: Responsible for displaying video call participants in a RecyclerView. It manages participant join/leave events, shows video streams, and updates the view when a participant's video starts or stops.

public class ParticipantAdapter extends RecyclerView.Adapter<ParticipantAdapter.PeerViewHolder> {

    private final List<Participant> participants = new ArrayList<>();

    public ParticipantAdapter(Meeting meeting) {
        // adding the local participant(You) to the list
        participants.add(meeting.getLocalParticipant());

        // adding Meeting Event listener to get the participant join/leave event in the meeting.
        meeting.addEventListener(new MeetingEventListener() {
            @Override
            public void onParticipantJoined(Participant participant) {
                // add participant to the list
                participants.add(participant);
                notifyItemInserted(participants.size() - 1);
            }

            @Override
            public void onParticipantLeft(Participant participant) {
                int pos = -1;
                for (int i = 0; i < participants.size(); i++) {
                    if (participants.get(i).getId().equals(participant.getId())) {
                        pos = i;
                        break;
                    }
                }
                // remove participant from the list
                participants.remove(participant);

                if (pos >= 0) {
                    notifyItemRemoved(pos);
                }
            }
        });
    }

    @NonNull
    @Override
    public PeerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return new PeerViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_remote_peer, parent, false));
    }

    @Override
    public void onBindViewHolder(@NonNull PeerViewHolder holder, int position) {
        Participant participant = participants.get(position);

        holder.tvName.setText(participant.getDisplayName());

        // adding the initial video stream for the participant into the 'VideoView'
        for (Map.Entry<String, Stream> entry : participant.getStreams().entrySet()) {
            Stream stream = entry.getValue();
            if (stream.getKind().equalsIgnoreCase("video")) {
                holder.participantView.setVisibility(View.VISIBLE);
                VideoTrack videoTrack = (VideoTrack) stream.getTrack();
                holder.participantView.addTrack(videoTrack);
                break;
            }
        }
        // add Listener to the participant which will update start or stop the video stream of that participant
        participant.addEventListener(new ParticipantEventListener() {
            @Override
            public void onStreamEnabled(Stream stream) {
                if (stream.getKind().equalsIgnoreCase("video")) {
                    holder.participantView.setVisibility(View.VISIBLE);
                    VideoTrack videoTrack = (VideoTrack) stream.getTrack();
                    holder.participantView.addTrack(videoTrack);
                }
            }

            @Override
            public void onStreamDisabled(Stream stream) {
                if (stream.getKind().equalsIgnoreCase("video")) {
                    holder.participantView.removeTrack();
                    holder.participantView.setVisibility(View.GONE);
                }
            }
        });
    }

    @Override
    public int getItemCount() {
        return participants.size();
    }

    static class PeerViewHolder extends RecyclerView.ViewHolder {
        // 'VideoView' to show Video Stream
        public VideoView participantView;
        public TextView tvName;
        public View itemView;

        PeerViewHolder(@NonNull View view) {
            super(view);
            itemView = view;
            tvName = view.findViewById(R.id.tvName);
            participantView = view.findViewById(R.id.participantView);
        }
    }

    @Override
    public void onViewRecycled(@NonNull PeerViewHolder holder) {
        holder.participantView.releaseSurfaceViewRenderer();
        super.onViewRecycled(holder);
    }
}

The meeting is set up on the callee's side, and now you need to set up the meeting on the caller's side as well.

When the callee accepts the call, the update-call function on the server is called, which triggers a silent notification to the caller.

class NetworkCallHandler  {

        fun updateCall(call_update: String){
        val fcmToken: String = MyFirebaseMessagingService.FCMtoken

        val callerInfo: MutableMap<String, String> = HashMap()
        val callUpdateBody: MutableMap<String, Any> = HashMap()

        //callerInfo
        callerInfo["callerId"] = myCallId
        callerInfo["token"] = fcmToken
        //CallUpdateBody
        callUpdateBody["callerInfo"] = callerInfo
        callUpdateBody["type"] = call_update

        val apiService = ApiClient.client!!.create(ApiService::class.java)
        val call : Call<String> = apiService.updateCall(callUpdateBody)
        call.enqueue(object :Callback<String>{
            override fun onFailure(call: Call<String>, t: Throwable) {
                Log.d("TAG", "onFailure: "+ t.message)
            }

            override fun onResponse(call: Call<String>, response: Response<String>) {
                Log.d("TAG", "Call updated successfully: " + response.body())
            }
        })
    }
}

When the caller's phone receives this notification, indicating either acceptance or rejection, the meeting is started, or a Toast message showing rejection is displayed, respectively.

class MyFirebaseMessagingService : FirebaseMessagingService() {
    
    @RequiresApi(Build.VERSION_CODES.O)
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val data = remoteMessage.data

        if (data.isNotEmpty()) {
            try {
                val `object` = JSONObject(data["info"]!!)

                val callerInfo = `object`.getJSONObject("callerInfo")
                callerID = callerInfo.getString("callerId")
                FCMtoken = callerInfo.getString("token")

                if (`object`.has("videoSDKInfo")) {
                    val videoSdkInfo = `object`.getJSONObject("videoSDKInfo")
                    meetingId = videoSdkInfo.getString("meetingId")
                    token = videoSdkInfo.getString("token")
                    handleIncomingCall(callerID)
                }

                val type = `object`.getString("type")
                when (type) {
                    "ACCEPTED" -> startMeeting()
                    "REJECTED" -> {
                        showIncomingCallNotification(callerID)
                        Handler(Looper.getMainLooper()).post {
                            Toast.makeText(applicationContext, "CALL REJECTED FROM CALLER ID: $callerID", Toast.LENGTH_SHORT).show()
                        }
                    }
                }

            } catch (e: JSONException) {
                throw RuntimeException(e)
            }
        } else {
            Log.d(TAG, "onMessageReceived: No data found in the notification payload.")
        }
    }

    private fun startMeeting() {
        val intent = Intent(applicationContext, MeetingActivity::class.java).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            putExtra("meetingId", MainApplication.meetingId)
            putExtra("token", MainApplication.token)
        }
        startActivity(intent)
    }
}

Here is how the video call will look with two participants:

Video SDK Image

Hurray!!! With these steps, our video calling feature is complete. Here’s a video demonstrating how it looks.

Conclusion

With this, we've successfully built the 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.

Here is the Github repo you can clone to access all the source code here