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
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.java
│ │ │ │ ├── Meeting/
│ │ │ │ │ ├── MeetingActivity.java
│ │ │ │ │ └── ParticipantAdapter.java
│ │ │ │ ├── Network/
│ │ │ │ │ ├── ApiClient.java
│ │ │ │ │ ├── ApiService.java
│ │ │ │ │ ├── NetworkCallhandler.java
│ │ │ │ │ └── NetworkUtils.java
│ │ │ │ ├── Services/
│ │ │ │ │ ├── CallConnectionService.java
│ │ │ │ │ ├── MyFirebaseMessagingService.java
│ │ │ │ │ └── MyInCallService.java
│ │ │ │ ├── MainActivity.java
│ │ │ │ ├── MainApplication.java
│ │ │ │ └── MeetingIdCallBack.java
│ │ ├── 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.
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
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
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == 133) {
boolean allPermissionsGranted = true;
for (int result : 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 static final int PERMISSION_REQ_ID = 22;
private static final String[] REQUESTED_PERMISSIONS = new String[]{
Manifest.permission.READ_PHONE_STATE,
Manifest.permission.POST_NOTIFICATIONS
};
private boolean checkSelfPermission(String permission, int requestCode) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, REQUESTED_PERMISSIONS, requestCode);
return false;
}
return true;
}
}
- 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.
public class MainActivity extends AppCompatActivity {
private EditText callerIdInput;
private TextView myId;
ImageView copyIcon;
String myCallId = String.valueOf(10000000 + new Random().nextInt(90000000));
@Override
protected void onCreate(Bundle savedInstanceState) {
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);
}
myId = findViewById(R.id.txt_callId);
callerIdInput = findViewById(R.id.caller_id_input);
copyIcon = findViewById(R.id.copyIcon);
Button callButton = findViewById(R.id.call_button);
myId.setText(myCallId);
ClipboardManager clipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = ClipData.newPlainText("copied text", myCallId);
copyIcon.setOnClickListener(v -> {
clipboardManager.setPrimaryClip(clipData);
Toast.makeText(this, "Copied to clipboard", Toast.LENGTH_SHORT).show();
});
NetworkUtils initMeeting = new NetworkUtils();
initMeeting.createMeeting(new MeetingIdCallBack() {
@Override
@Async
public void onMeetingIdReceived(String meetingId, String token) {
MainApplication.setMeetingId(meetingId);
}
});
NetworkCallHandler.myCallId = myCallId;
DatabaseUtils.myCallId = myCallId;
//Firebase Notification
NotificationChannel channel = new NotificationChannel("notification_channel", "notification_channel", NotificationManager.IMPORTANCE_DEFAULT);
NotificationManager manager = getSystemService(NotificationManager.class);
manager.createNotificationChannel(channel);
FirebaseMessaging.getInstance().subscribeToTopic("general")
.addOnCompleteListener(new OnCompleteListener<Void>() {
@Override
public void onComplete(@NonNull Task<Void> task) {
String msg = "Subscribed Successfully";
if (!task.isSuccessful()) {
msg = "Subscription failed";
}
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show();
}
});
//Firebase Database Actions
DatabaseUtils databaseUtils = new DatabaseUtils();
DatabaseReference databaseReference = FirebaseDatabase.getInstance().getReference();
FirebaseMessaging.getInstance().getToken().addOnCompleteListener( task -> {
NetworkCallHandler.FcmToken = task.getResult();
DatabaseUtils.FcmToken= task.getResult();
databaseUtils.sendUserDataToFirebase(databaseReference);
});
callButton.setOnClickListener(v -> {
String callerNumber = callerIdInput.getText().toString();
if (callerNumber.length() == 8) {
databaseUtils.retrieveUserData(databaseReference, callerNumber);
} else {
Toast.makeText(MainActivity.this, "Please input the correct caller ID", Toast.LENGTH_SHORT).show();
}
});
}
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.
public class DatabaseUtils {
String calleeInfoToken ;
public static String FcmToken ;
public static String myCallId;
public void sendUserDataToFirebase(DatabaseReference databaseReference) {
DatabaseReference usersRef = databaseReference.child("User");
usersRef.orderByChild("token").equalTo(FcmToken).addListenerForSingleValueEvent(new ValueEventListener() {
@Override
public void onDataChange(@NonNull DataSnapshot dataSnapshot) {
if (dataSnapshot.exists()) {
// Token exists, update the callerId
for (DataSnapshot userSnapshot : dataSnapshot.getChildren()) {
userSnapshot.getRef().child("callerId").setValue(myCallId)
.addOnSuccessListener(aVoid -> {
Log.d("FirebaseData", "CallerId successfully updated.");
})
.addOnFailureListener(e -> {
Log.e("FirebaseError", "Failed to update callerId.", e);
});
}
} else {
// Token doesn't exist, create new entry
String userId = usersRef.push().getKey();
Map<String, Object> map = new HashMap<>();
map.put("callerId", myCallId);
map.put("token", FcmToken);
if (userId != null) {
usersRef.child(userId).setValue(map)
.addOnSuccessListener(aVoid -> {
Log.d("FirebaseData", "Data successfully saved.");
})
.addOnFailureListener(e -> {
Log.e("FirebaseError", "Failed to save data.", e);
});
}
}
}
@Override
public void onCancelled(@NonNull 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.
public class DatabaseUtils {
//...
public void retrieveUserData(DatabaseReference databaseReference, String callerNumber) {
NetworkCallHandler callHandler = new NetworkCallHandler();
databaseReference.child("User").orderByChild("callerId").equalTo(callerNumber).addListenerForSingleValueEvent(new ValueEventListener() {
@Override
public void onDataChange(@NonNull DataSnapshot snapshot) {
if (snapshot.exists()) {
for (DataSnapshot data : snapshot.getChildren()) {
String token = data.child("token").getValue(String.class);
if (token != null) {
calleeInfoToken = token;
NetworkCallHandler.calleeInfoToken = token;
callHandler.initiateCall();
break;
}
}
} else {
Log.d("TAG", "retrieveUserData: No matching callerId found");
}
}
@Override
public void onCancelled(@NonNull DatabaseError error) {
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.
public class NetworkUtils {
String sampleToken = MainApplication.getToken();
public void createMeeting(MeetingIdCallBack callBack) {
// we will make an API call to VideoSDK Server to get a roomId
AndroidNetworking.post("https://api.videosdk.live/v2/rooms")
.addHeaders("Authorization", sampleToken) //we will pass the token in the Headers
.build()
.getAsJSONObject(new JSONObjectRequestListener() {
@Override
public void onResponse(JSONObject response) {
try {
// response will contain `meetingID`
final String meetingId = response.getString("roomId");
callBack.onMeetingIdReceived(meetingId,sampleToken);
} catch (JSONException e) {
e.printStackTrace();
}
}
@Override
public void onError(ANError anError) {
anError.printStackTrace();
}
});
}
}
The MeetingIdCallBack
interface allows us to receive the meetingId
and token
in our MainActivity.
public interface MeetingIdCallBack {
void onMeetingIdReceived(String meetingId,String token);
}
using onMeetingIdReceived
callback method to get meetingId
and token
.
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//...
NetworkUtils networkUtils = new NetworkUtils();
networkUtils.createMeeting(new MeetingIdCallBack() {
@Override
public void onMeetingIdReceived(String meetingId, String token) {
MainApplication.setMeetingId(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.
public class ApiClient {
private static final String BASE_URL = "FCM_SERVER_URL";
private static Retrofit retrofit = null;
public static Retrofit getClient() {
if (retrofit == null) {
retrofit = new 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.
public interface ApiService {
@POST("/initiate-call")
Call<String> initiateCall(@Body Map<String, Object> callRequestBody);
@POST("/update-call")
Call<String> updateCall(@Body Map<String,Object> callUpdateBody);
}
Initiates a call by sending caller, callee, and VideoSDK information to the server
and handles the server response and logs success or failure accordingly.
public class NetworkCallHandler {
static ApiService apiService = ApiClient.getClient().create(ApiService.class);
public static String myCallId;
public static String FcmToken;
public static String calleeInfoToken;
public void initiateCall() {
ApiService apiService = ApiClient.getClient().create(ApiService.class);
Map<String,String> callerInfo = new HashMap<>();
Map<String,String> calleeInfo = new HashMap<>();
Map <String,String> videoSDKInfo= new HashMap<>();
//callerInfo
callerInfo.put("callerId",myCallId);
callerInfo.put("token",FcmToken);
//calleeInfo
calleeInfo.put("token",calleeInfoToken);
//videoSDKInfo
videoSDKInfo.put("meetingId", MainApplication.getMeetingId());
videoSDKInfo.put("token",MainApplication.getToken());
Map<String,Object> callRequestBody = new HashMap<>();
callRequestBody.put("callerInfo",callerInfo);
callRequestBody.put("calleeInfo",calleeInfo);
callRequestBody.put("videoSDKInfo",videoSDKInfo);
Call<String> call = apiService.initiateCall(callRequestBody);
call.enqueue(new Callback<String>() {
@Override
public void onResponse(@NonNull Call<String> call, @NonNull Response<String> response) {
if (response.isSuccessful()) {
Log.d("API", "Call initiated: " + response.body());
} else {
Log.e("API", "Failed to initiate call: " + response.message());
}
}
@Override
public void onFailure(@NonNull Call<String> call, @NonNull Throwable t) {
Log.e("API", "API call failed: " + t.getMessage());
}
});
}
}
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.
public class MyFirebaseMessagingService extends FirebaseMessagingService {
private static final String TAG = "FCMService";
private static final String CHANNEL_ID = "notification_channel";
String callerID;
String meetingId ;
String token ;
public static String FCMtoken;
@Override
public void onNewToken(@NonNull String token) {
super.onNewToken(token);
}
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
// Handle incoming message (call request)
Map<String, String> data = remoteMessage.getData();
if (!data.isEmpty()) {
try {
JSONObject object = new JSONObject(data.get("info"));
JSONObject callerInfo = object.getJSONObject("callerInfo");
callerID = callerInfo.getString("callerId");
FCMtoken = callerInfo.getString("token");
if (object.has("videoSDKInfo")) {
JSONObject videoSdkInfo = object.getJSONObject("videoSDKInfo");
meetingId = videoSdkInfo.getString("meetingId");
token = videoSdkInfo.getString("token");
handleIncomingCall(callerID);
}
String type = (String) object.get("type");
if(type.equals("ACCEPTED")){
startMeeting();
} else if (type.equals("REJECTED")) {
showIncomingCallNotification(callerID);
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
Toast toast = Toast.makeText(getApplicationContext(), "CALL REJECTED FROM CALLER ID: " + callerID, Toast.LENGTH_SHORT);
toast.show();
}
});
}
} catch (JSONException e) {
throw new RuntimeException(e);
}
} else {
Log.d(TAG, "onMessageReceived: No data found in the notification payload.");
}
}
private void startMeeting() {
Intent intent = new Intent(getApplicationContext(), MeetingActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra("meetingId", MainApplication.getMeetingId());
intent.putExtra("token", MainApplication.getToken());
startActivity(intent);
}
private void handleIncomingCall(String callerId) {
// Create a bundle to pass call details
Bundle extras = new Bundle();
Uri uri = Uri.fromParts("tel", callerId, null);
extras.putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, uri);
extras.putString("meetingId", meetingId);
extras.putString("token", token);
extras.putString("callerID",callerId);
try {
MainApplication.telecomManager.addNewIncomingCall(MainApplication.phoneAccountHandle, extras);
} catch (Throwable cause) {
Log.e("handleIncomingCall", "error in addNewIncomingCall ", cause.getCause());
}
}
private void showIncomingCallNotification(String callerId) {
createNotificationChannel();
Intent intent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_MUTABLE);
Notification notification = new 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();
NotificationManager notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
notificationManager.notify(1, notification);
}
private void createNotificationChannel() {
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "Incoming Calls", NotificationManager.IMPORTANCE_HIGH);
NotificationManager notificationManager = getSystemService(NotificationManager.class);
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.
public class MainApplication extends Application {
public static TelecomManager telecomManager;
public static PhoneAccountHandle phoneAccountHandle;
static String meetingId;
static String token="VideoSDK token";
public static void setMeetingId(String meetingId) {
MainApplication.meetingId = meetingId;
}
public static String getMeetingId(){
return meetingId;
}
public static String getToken() {
return token;
}
@Override
public void onCreate() {
super.onCreate();
VideoSDK.initialize(getApplicationContext());
telecomManager = (TelecomManager) getSystemService(TELECOM_SERVICE);
ComponentName componentName = new ComponentName(this, CallConnectionService.class);
phoneAccountHandle = new PhoneAccountHandle(componentName, "myAccountId");
}
}
Now, In MainActivity
we'll register the PhoneAccount and check for permission to manage Call Activity.
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//telecom Api
registerPhoneAccount();
}
private void registerPhoneAccount() {
PhoneAccount 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;
}
int checkAccount=0;
List<PhoneAccountHandle> list = MainApplication.telecomManager.getCallCapablePhoneAccounts();
for (PhoneAccountHandle handle:
list) {
if(handle.getComponentName().getClassName().equals("live.videosdk.ConnectionService.quickstart.Services.CallConnectionService"))
{
checkAccount++;
break;
}
}
if(checkAccount == 0) {
Intent intent = new 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.
public class CallConnectionService extends ConnectionService {
String callerID;
@Override
public Connection onCreateIncomingConnection(PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
// Create a connection for the incoming call
Connection connection = new Connection() {
@Override
public void onAnswer() {
super.onAnswer();
//getting videosdk info
Bundle extras = request.getExtras();
String meetingId = extras.getString("meetingId");
String token = extras.getString("token");
callerID = extras.getString("callerID");
// Start the meeting activity with the extracted data
Intent intent = new Intent(getApplicationContext(), MeetingActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra("meetingId", meetingId);
intent.putExtra("token", token);
startActivity(intent);
NetworkCallHandler.updateCall("ACCEPTED");
//update
setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
destroy();
}
@Override
public void onReject() {
super.onReject();
Intent intent = new Intent(getApplicationContext(), MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
//update
NetworkCallHandler.updateCall("REJECTED");
setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
destroy();
}
};
// Set call address
connection.setAddress(request.getAddress(), 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
public Connection onCreateOutgoingConnection(PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
// Create a connection for the outgoing call
Connection connection = new Connection(){};
connection.setAddress(request.getAddress(), TelecomManager.PRESENTATION_ALLOWED);
connection.setActive();
return connection;
}
}
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
.
public class MyInCallService extends InCallService {
@Override
public void onCallAdded(Call call) {
super.onCallAdded(call);
call.registerCallback(new Call.Callback() {
@Override
public void onStateChanged(Call call, int state) {
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
public void onCallRemoved(Call call) {
super.onCallRemoved(call);
// Clean up call-related resources
}
private void setUpDefaultCallUI(Call call) {
// Start the default in-call UI
TelecomManager telecomManager = (TelecomManager) getSystemService(TELECOM_SERVICE);
if (telecomManager != null) {
if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
return;
}
telecomManager.showInCallScreen(true);
}
}
}
Add the service to AndroidMenifest.xml
<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.java
public class MainApplication extends Application {
}
@Override
public void onCreate() {
super.onCreate();
VideoSDK.initialize(getApplicationContext());
//...
}
}
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.
public class CallConnectionService extends ConnectionService {
String callerID;
@Override
public Connection onCreateIncomingConnection(PhoneAccountHandle connectionManagerPhoneAccount, ConnectionRequest request) {
// Create a connection for the incoming call
Connection connection = new Connection() {
@Override
public void onAnswer() {
super.onAnswer();
//...
Intent intent = new Intent(getApplicationContext(), MeetingActivity.class);
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
public class MeetingActivity extends AppCompatActivity {
private static final int PERMISSION_REQ_ID = 22;
private static final String[] REQUESTED_PERMISSIONS = {
android.Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA
};
// declare the variables we will be using to handle the meeting
private Meeting meeting;
private boolean micEnabled = true;
private boolean webcamEnabled = true;
private RecyclerView rvParticipants;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_meeting);
checkSelfPermission(REQUESTED_PERMISSIONS[0], PERMISSION_REQ_ID);
checkSelfPermission(REQUESTED_PERMISSIONS[1], PERMISSION_REQ_ID);
final String token = getIntent().getStringExtra("token");
final String meetingId = getIntent().getStringExtra("meetingId");
final String participantName = "John Doe";
// 1. Configuration VideoSDK with Token
VideoSDK.config(token);
// 2. Initialize VideoSDK Meeting
meeting = VideoSDK.initMeeting(
MeetingActivity.this, 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();
((TextView)findViewById(R.id.tvMeetingId)).setText(meetingId);
// actions
setActionListeners();
rvParticipants = findViewById(R.id.rvParticipants);
rvParticipants.setLayoutManager(new GridLayoutManager(this, 2));
rvParticipants.setAdapter(new ParticipantAdapter(meeting));
}
// creating the MeetingEventListener
private final MeetingEventListener meetingEventListener = new MeetingEventListener() {
@Override
public void onMeetingJoined() {
Log.d("#meeting", "onMeetingJoined()");
}
@Override
public void onMeetingLeft() {
Log.d("#meeting", "onMeetingLeft()");
meeting = null;
if (!isDestroyed()) finish();
}
@Override
public void onParticipantJoined(Participant participant) {
Toast.makeText(MeetingActivity.this, participant.getDisplayName() + " joined", Toast.LENGTH_SHORT).show();
}
@Override
public void onParticipantLeft(Participant participant) {
Toast.makeText(MeetingActivity.this, participant.getDisplayName() + " left", Toast.LENGTH_SHORT).show();
}
};
private void setActionListeners() {
// toggle mic
findViewById(R.id.btnMic).setOnClickListener(view -> {
if (micEnabled) {
// this will mute the local participant's mic
meeting.muteMic();
Toast.makeText(MeetingActivity.this, "Mic Disabled", Toast.LENGTH_SHORT).show();
} else {
// this will unmute the local participant's mic
meeting.unmuteMic();
Toast.makeText(MeetingActivity.this, "Mic Enabled", Toast.LENGTH_SHORT).show();
}
micEnabled=!micEnabled;
});
// toggle webcam
findViewById(R.id.btnWebcam).setOnClickListener(view -> {
if (webcamEnabled) {
// this will disable the local participant webcam
meeting.disableWebcam();
Toast.makeText(MeetingActivity.this, "Webcam Disabled", Toast.LENGTH_SHORT).show();
} else {
// this will enable the local participant webcam
meeting.enableWebcam();
Toast.makeText(MeetingActivity.this, "Webcam Enabled", Toast.LENGTH_SHORT).show();
}
webcamEnabled=!webcamEnabled;
});
// leave meeting
findViewById(R.id.btnLeave).setOnClickListener(view -> {
// this will make the local participant leave the meeting
meeting.leave();
});
}
@Override
protected void onDestroy() {
rvParticipants.setAdapter(null);
super.onDestroy();
}
private boolean checkSelfPermission(String permission, int requestCode) {
if (ContextCompat.checkSelfPermission(this, permission) !=
PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, REQUESTED_PERMISSIONS, requestCode);
return false;
}
return true;
}
}
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.
public class NetworkCallHandler {
public static void updateCall(String call_update) {
String fcmToken = MyFirebaseMessagingService.FCMtoken;
Map<String, String> callerInfo = new HashMap<>();
Map<String, Object> callUpdateBody = new HashMap<>();
//callerInfo
callerInfo.put("callerId",myCallId);
callerInfo.put("token",fcmToken);
//CallUpdateBody
callUpdateBody.put("callerInfo",callerInfo);
callUpdateBody.put("type",call_update);
Call<String> call = apiService.updateCall(callUpdateBody);
call.enqueue(new Callback<String>() {
@Override
public void onResponse(@NonNull Call<String> call, @NonNull Response<String> response) {
if (response.isSuccessful()) {
Log.d("API", "Call updated successfully: " + response.body());
}
}
@Override
public void onFailure(@NonNull Call<String> call, @NonNull Throwable t) {
Log.e("API", "Call update failed", t);
}
});
}
}
When the caller's phone receives this notification, it either starts the meeting upon acceptance or displays a Toast message indicating rejection.
public class MyFirebaseMessagingService extends FirebaseMessagingService {
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
// Handle incoming message (call request)
Map<String, String> data = remoteMessage.getData();
if (!data.isEmpty()) {
try {
JSONObject object = new JSONObject(data.get("info"));
JSONObject callerInfo = object.getJSONObject("callerInfo");
callerID = callerInfo.getString("callerId");
FCMtoken = callerInfo.getString("token");
if (object.has("videoSDKInfo")) {
JSONObject videoSdkInfo = object.getJSONObject("videoSDKInfo");
meetingId = videoSdkInfo.getString("meetingId");
token = videoSdkInfo.getString("token");
handleIncomingCall(callerID);
}
String type = (String) object.get("type");
if(type.equals("ACCEPTED")){
startMeeting();
} else if (type.equals("REJECTED")) {
showIncomingCallNotification(callerID);
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
Toast toast = Toast.makeText(getApplicationContext(), "CALL REJECTED FROM CALLER ID: " + callerID, Toast.LENGTH_SHORT);
toast.show();
}
});
}
} catch (JSONException e) {
throw new RuntimeException(e);
}
} else {
Log.d(TAG, "onMessageReceived: No data found in the notification payload.");
}
}
}
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 Call Trigger, 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