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:
- User Action: John enters Max's Caller ID and initiates the call.
- Database and Notification: The app maps the ID in the Firebase database and sends a notification to Max's device.
- Incoming Call UI: Max’s device receives the notification, triggering the incoming call UI using the Telecom Framework.
- Call Connection: If Max accepts, the video call begins using VideoSDK.
Here is a pictorial representation of the flow for better understanding.
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
- In your
settings.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" }
}
}
- 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
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’sapp/
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:
- Go to the Firebase Console and select your project.
- In Project Settings, navigate to the Service Accounts tab.
- 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.
This is how the UI will look:
With the UI for calling in place let's start with the actual calling development.
Firebase Messaging for Call Initiation
- 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
)
}
}
- 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
}
})
}
}
- 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.xml
The 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.
VideoSDK Integration for VideoCall
- 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:
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