Skip to main content
Implement native VoIP calling that works when your app is in the background or killed. This guide shows how to integrate Android’s Telecom framework with CometChat to display system call UI, handle calls from the lock screen, and provide a native calling experience.

Overview

VoIP calling differs from basic in-app ringing by leveraging Android’s ConnectionService to:
  • Show incoming calls on lock screen with system UI
  • Handle calls when app is in background or killed
  • Integrate with Bluetooth, car systems, and wearables
  • Provide consistent call experience across Android devices

Prerequisites

Before implementing VoIP calling, ensure you have:
This documentation builds on the Ringing functionality. Make sure you understand basic call signaling before implementing VoIP.

Architecture Overview

The VoIP implementation consists of several components working together:
ComponentPurpose
FirebaseMessagingServiceReceives push notifications for incoming calls when app is in background
ConnectionServiceAndroid Telecom framework integration - manages call state with the system
CallNotificationManagerDecides whether to show system call UI or fallback notification
PhoneAccountRegisters your app as a calling app with Android’s Telecom system
ConnectionRepresents an individual call and handles user actions (accept/reject/hold)

Step 1: Configure Push Notifications

Push notifications are essential for receiving incoming calls when your app is not in the foreground. When a call is initiated, CometChat sends a push notification to the receiver’s device.
For detailed FCM setup instructions, see the Android Push Notifications documentation.

1.1 Add FCM Dependencies

Add Firebase Messaging to your build.gradle:
dependencies {
    implementation 'com.google.firebase:firebase-messaging:23.4.0'
}

1.2 Create FirebaseMessagingService

This service receives push notifications from FCM. When a call notification arrives, it extracts the call data and decides how to display the incoming call based on the app’s state.
class CallFirebaseMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val data = remoteMessage.data
        
        // Check if this is a call notification by examining the "type" field
        // CometChat sends call notifications with type="call"
        if (data["type"] == "call") {
            handleIncomingCall(data)
        }
    }

    private fun handleIncomingCall(data: Map<String, String>) {
        // Extract call information from the push payload
        // These fields are sent by CometChat when a call is initiated
        val sessionId = data["sessionId"] ?: return
        val callerName = data["senderName"] ?: "Unknown"
        val callerUid = data["senderUid"] ?: return
        val callType = data["callType"] ?: "video"  // "audio" or "video"
        val callerAvatar = data["senderAvatar"]

        // Create a CallData object to pass call information between components
        val callData = CallData(
            sessionId = sessionId,
            callerName = callerName,
            callerUid = callerUid,
            callType = callType,
            callerAvatar = callerAvatar
        )

        // If app is in foreground, let CometChat's CallListener handle it
        // This provides a seamless experience with in-app UI
        if (isAppInForeground()) {
            return
        }

        // App is in background/killed - show system call UI via ConnectionService
        // This ensures the user sees the incoming call even when not using the app
        CallNotificationManager.showIncomingCall(this, callData)
    }

    /**
     * Checks if the app is currently visible to the user.
     * We only want to use ConnectionService when the app is in background.
     */
    private fun isAppInForeground(): Boolean {
        val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
        val appProcesses = activityManager.runningAppProcesses ?: return false
        val packageName = packageName
        for (appProcess in appProcesses) {
            if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
                && appProcess.processName == packageName) {
                return true
            }
        }
        return false
    }

    /**
     * Called when FCM generates a new token.
     * Register this token with CometChat to receive push notifications.
     */
    override fun onNewToken(token: String) {
        // Register the FCM token with CometChat's push notification service
        // This links the device to the logged-in user for push delivery
        CometChat.registerTokenForPushNotification(token, object : CometChat.CallbackListener<String>() {
            override fun onSuccess(s: String) {
                Log.d(TAG, "Push token registered successfully")
            }
            override fun onError(e: CometChatException) {
                Log.e(TAG, "Token registration failed: ${e.message}")
            }
        })
    }

    companion object {
        private const val TAG = "CallFCMService"
    }
}

1.3 Create CallData Model

The CallData class is a simple data container that holds all information about an incoming or outgoing call. It implements Parcelable so it can be passed between Android components (Activities, Services, BroadcastReceivers).
/**
 * Data class representing call information.
 * Implements Parcelable to allow passing between Android components.
 */
data class CallData(
    val sessionId: String,      // Unique identifier for the call session
    val callerName: String,     // Display name of the caller
    val callerUid: String,      // CometChat UID of the caller
    val callType: String,       // "audio" or "video"
    val callerAvatar: String?   // URL to caller's avatar image (optional)
) : Parcelable {
    
    constructor(parcel: Parcel) : this(
        parcel.readString() ?: "",
        parcel.readString() ?: "",
        parcel.readString() ?: "",
        parcel.readString() ?: "",
        parcel.readString()
    )

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(sessionId)
        parcel.writeString(callerName)
        parcel.writeString(callerUid)
        parcel.writeString(callType)
        parcel.writeString(callerAvatar)
    }

    override fun describeContents(): Int = 0

    companion object CREATOR : Parcelable.Creator<CallData> {
        override fun createFromParcel(parcel: Parcel): CallData = CallData(parcel)
        override fun newArray(size: Int): Array<CallData?> = arrayOfNulls(size)
    }
}

Step 2: Register PhoneAccount

A PhoneAccount tells Android that your app can handle phone calls. This registration is required for the system to route incoming calls to your app and display the native call UI.

2.1 Create PhoneAccountManager

This singleton class handles registering your app with Android’s Telecom system. The PhoneAccount must be registered before you can receive or make VoIP calls.
/**
 * Manages PhoneAccount registration with Android's Telecom system.
 * Must be called once when the app starts (typically in Application.onCreate).
 */
object PhoneAccountManager {
    private const val PHONE_ACCOUNT_ID = "cometchat_voip_account"
    private var phoneAccountHandle: PhoneAccountHandle? = null

    /**
     * Registers your app as a calling app with Android's Telecom system.
     * Call this in your Application.onCreate() method.
     */
    fun register(context: Context) {
        val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
        
        // ComponentName points to your ConnectionService implementation
        val componentName = ComponentName(context, CallConnectionService::class.java)
        
        // PhoneAccountHandle uniquely identifies your calling account
        phoneAccountHandle = PhoneAccountHandle(componentName, PHONE_ACCOUNT_ID)

        // Build the PhoneAccount with required capabilities
        val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "CometChat Calls")
            .setCapabilities(
                // CAPABILITY_CALL_PROVIDER: Can make and receive calls
                // CAPABILITY_SELF_MANAGED: Manages its own call UI (required for VoIP)
                PhoneAccount.CAPABILITY_CALL_PROVIDER or
                PhoneAccount.CAPABILITY_SELF_MANAGED
            )
            .build()

        // Register with the system
        telecomManager.registerPhoneAccount(phoneAccount)
    }

    /**
     * Returns the PhoneAccountHandle for use with TelecomManager calls.
     */
    fun getPhoneAccountHandle(context: Context): PhoneAccountHandle {
        if (phoneAccountHandle == null) {
            val componentName = ComponentName(context, CallConnectionService::class.java)
            phoneAccountHandle = PhoneAccountHandle(componentName, PHONE_ACCOUNT_ID)
        }
        return phoneAccountHandle!!
    }

    /**
     * Checks if the user has enabled the PhoneAccount in system settings.
     * Some devices require manual enabling in Settings > Apps > Phone > Calling accounts.
     */
    fun isPhoneAccountEnabled(context: Context): Boolean {
        val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
        val account = telecomManager.getPhoneAccount(getPhoneAccountHandle(context))
        return account?.isEnabled == true
    }

    /**
     * Opens system settings where user can enable the PhoneAccount.
     * Call this if isPhoneAccountEnabled() returns false.
     */
    fun openPhoneAccountSettings(context: Context) {
        val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS)
        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
        context.startActivity(intent)
    }
}

2.2 Register on App Start

Register the PhoneAccount when your app starts. This should be done in your Application class to ensure it’s registered before any calls can be received.
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        
        // Initialize CometChat (see Setup guide)
        // CometChat.init(...)
        
        // Register PhoneAccount for VoIP calling
        // This must be done before receiving any calls
        PhoneAccountManager.register(this)
    }
}

Step 3: Implement ConnectionService

The ConnectionService is the core component that bridges your app with Android’s Telecom framework. It creates Connection objects that represent individual calls and handle user interactions.

3.1 Create CallConnectionService

This service is called by Android when a new incoming or outgoing call needs to be created. It’s responsible for creating Connection objects that manage the call state.
/**
 * ConnectionService implementation for VoIP calling.
 * Android's Telecom framework calls this service to create Connection objects
 * for incoming and outgoing calls.
 */
class CallConnectionService : ConnectionService() {

    /**
     * Called by Android when an incoming call is reported via TelecomManager.addNewIncomingCall().
     * Creates a Connection object that will handle the incoming call.
     */
    override fun onCreateIncomingConnection(
        connectionManagerPhoneAccount: PhoneAccountHandle?,
        request: ConnectionRequest?
    ): Connection {
        // Extract call data from the request extras
        val extras = request?.extras
        val callData = extras?.getParcelable<CallData>(EXTRA_CALL_DATA)
        
        // Create a new Connection to represent this call
        val connection = CallConnection(applicationContext, callData)
        
        // Set initial state: initializing -> ringing
        // This triggers the system to show the incoming call UI
        connection.setInitializing()
        connection.setRinging()
        
        // Store the connection so we can access it from other components
        CallConnectionHolder.setConnection(connection)
        
        return connection
    }

    /**
     * Called by Android when an outgoing call is placed via TelecomManager.placeCall().
     * Creates a Connection object that will handle the outgoing call.
     */
    override fun onCreateOutgoingConnection(
        connectionManagerPhoneAccount: PhoneAccountHandle?,
        request: ConnectionRequest?
    ): Connection {
        val extras = request?.extras
        val callData = extras?.getParcelable<CallData>(EXTRA_CALL_DATA)
        
        val connection = CallConnection(applicationContext, callData)
        
        // Set initial state: initializing -> dialing
        // This triggers the system to show the outgoing call UI
        connection.setInitializing()
        connection.setDialing()
        
        CallConnectionHolder.setConnection(connection)
        
        return connection
    }

    /**
     * Called when the system fails to create an incoming connection.
     * This can happen due to permission issues or system constraints.
     */
    override fun onCreateIncomingConnectionFailed(
        connectionManagerPhoneAccount: PhoneAccountHandle?,
        request: ConnectionRequest?
    ) {
        Log.e(TAG, "Failed to create incoming connection")
        // Consider showing a fallback notification here
    }

    /**
     * Called when the system fails to create an outgoing connection.
     */
    override fun onCreateOutgoingConnectionFailed(
        connectionManagerPhoneAccount: PhoneAccountHandle?,
        request: ConnectionRequest?
    ) {
        Log.e(TAG, "Failed to create outgoing connection")
    }

    companion object {
        private const val TAG = "CallConnectionService"
        const val EXTRA_CALL_DATA = "extra_call_data"
    }
}

3.2 Create CallConnection

The Connection class represents an individual call. It receives callbacks from Android when the user interacts with the call (answer, reject, hold, etc.) and is responsible for updating the call state and communicating with CometChat.
/**
 * Represents an individual VoIP call.
 * Handles user actions (answer, reject, disconnect) and bridges to CometChat SDK.
 */
class CallConnection(
    private val context: Context,
    private val callData: CallData?
) : Connection() {

    init {
        // PROPERTY_SELF_MANAGED: We manage our own call UI (not using system dialer)
        connectionProperties = PROPERTY_SELF_MANAGED
        
        // Set capabilities for this call
        // CAPABILITY_MUTE: User can mute the call
        // CAPABILITY_SUPPORT_HOLD/CAPABILITY_HOLD: User can put call on hold
        connectionCapabilities = CAPABILITY_MUTE or 
                                 CAPABILITY_SUPPORT_HOLD or 
                                 CAPABILITY_HOLD
        
        // Set caller information for the system call UI
        callData?.let {
            // Display name shown on incoming call screen
            setCallerDisplayName(it.callerName, TelecomManager.PRESENTATION_ALLOWED)
            // Address (used for call log and display)
            setAddress(
                Uri.parse("tel:${it.callerUid}"),
                TelecomManager.PRESENTATION_ALLOWED
            )
        }
        
        // Mark this as a VoIP call for proper audio routing
        audioModeIsVoip = true
    }

    /**
     * Called when user taps "Answer" on the incoming call screen.
     * Accept the call via CometChat and launch the call activity.
     */
    override fun onAnswer() {
        Log.d(TAG, "Call answered by user")
        
        // Update connection state to active (call is now connected)
        setActive()
        
        callData?.let { data ->
            // Accept the call via CometChat Chat SDK
            // This notifies the caller that we've accepted
            CometChat.acceptCall(data.sessionId, object : CometChat.CallbackListener<Call>() {
                override fun onSuccess(call: Call) {
                    Log.d(TAG, "CometChat call accepted successfully")
                    // Launch the call activity to show the video/audio UI
                    launchCallActivity(data)
                }

                override fun onError(e: CometChatException) {
                    Log.e(TAG, "Failed to accept call: ${e.message}")
                    // Call failed - disconnect and clean up
                    setDisconnected(DisconnectCause(DisconnectCause.ERROR))
                    destroy()
                }
            })
        }
    }

    /**
     * Called when user taps "Decline" on the incoming call screen.
     * Reject the call via CometChat.
     */
    override fun onReject() {
        Log.d(TAG, "Call rejected by user")
        
        callData?.let { data ->
            // Reject the call via CometChat Chat SDK
            // This notifies the caller that we've declined
            CometChat.rejectCall(
                data.sessionId,
                CometChatConstants.CALL_STATUS_REJECTED,
                object : CometChat.CallbackListener<Call>() {
                    override fun onSuccess(call: Call) {
                        Log.d(TAG, "Call rejected successfully")
                    }
                    override fun onError(e: CometChatException) {
                        Log.e(TAG, "Failed to reject call: ${e.message}")
                    }
                }
            )
        }
        
        // Update connection state and clean up
        setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
        destroy()
    }

    /**
     * Called when user ends the call (taps end call button).
     * Leave the call session and notify the other participant.
     */
    override fun onDisconnect() {
        Log.d(TAG, "Call disconnected")
        
        // Leave the Calls SDK session if active
        if (CallSession.getInstance().isSessionActive) {
            CallSession.getInstance().leaveSession()
        }
        
        // End the call via CometChat Chat SDK
        // This notifies the other participant that the call has ended
        callData?.let { data ->
            CometChat.endCall(data.sessionId, object : CometChat.CallbackListener<Call>() {
                override fun onSuccess(call: Call) {
                    Log.d(TAG, "Call ended successfully")
                }
                override fun onError(e: CometChatException) {
                    Log.e(TAG, "Failed to end call: ${e.message}")
                }
            })
        }
        
        setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
        destroy()
    }

    /**
     * Called when user puts the call on hold.
     */
    override fun onHold() {
        setOnHold()
        // Mute audio when on hold
        CallSession.getInstance().muteAudio()
    }

    /**
     * Called when user takes the call off hold.
     */
    override fun onUnhold() {
        setActive()
        CallSession.getInstance().unMuteAudio()
    }

    /**
     * Launches the CallActivity to show the call UI.
     */
    private fun launchCallActivity(callData: CallData) {
        val intent = Intent(context, CallActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
            putExtra(CallActivity.EXTRA_SESSION_ID, callData.sessionId)
            putExtra(CallActivity.EXTRA_CALL_TYPE, callData.callType)
            putExtra(CallActivity.EXTRA_IS_INCOMING, true)
        }
        context.startActivity(intent)
    }

    /**
     * Public method to end the call from outside this class.
     */
    fun endCall() {
        onDisconnect()
    }

    companion object {
        private const val TAG = "CallConnection"
    }
}

3.3 Create CallConnectionHolder

This singleton holds a reference to the active Connection so it can be accessed from other components (like the CallActivity or BroadcastReceiver).
/**
 * Singleton to hold the active CallConnection.
 * Allows other components to access and control the current call.
 */
object CallConnectionHolder {
    private var connection: CallConnection? = null

    fun setConnection(conn: CallConnection?) {
        connection = conn
    }

    fun getConnection(): CallConnection? = connection

    /**
     * Ends the current call and clears the reference.
     */
    fun endCall() {
        connection?.endCall()
        connection = null
    }

    fun hasActiveConnection(): Boolean = connection != null
}

Step 4: Create CallNotificationManager

This class is responsible for showing incoming calls to the user. It first tries to use the system call UI via TelecomManager, and falls back to a high-priority notification if that fails.
/**
 * Manages showing incoming calls via Android's Telecom system.
 * Falls back to a high-priority notification if Telecom fails.
 */
object CallNotificationManager {

    /**
     * Shows an incoming call to the user.
     * Tries to use the system call UI first, falls back to notification.
     */
    fun showIncomingCall(context: Context, callData: CallData) {
        val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
        
        // Prepare extras with call data for the ConnectionService
        val extras = Bundle().apply {
            putParcelable(CallConnectionService.EXTRA_CALL_DATA, callData)
            putParcelable(
                TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE,
                PhoneAccountManager.getPhoneAccountHandle(context)
            )
        }

        try {
            // Tell Android there's an incoming call
            // This triggers onCreateIncomingConnection in our ConnectionService
            telecomManager.addNewIncomingCall(
                PhoneAccountManager.getPhoneAccountHandle(context),
                extras
            )
        } catch (e: SecurityException) {
            // Permission denied - PhoneAccount may not be enabled
            Log.e(TAG, "Permission denied for incoming call: ${e.message}")
            showFallbackNotification(context, callData)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to show incoming call: ${e.message}")
            showFallbackNotification(context, callData)
        }
    }

    /**
     * Places an outgoing call via the Telecom system.
     */
    fun placeOutgoingCall(context: Context, callData: CallData) {
        val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
        
        val extras = Bundle().apply {
            putParcelable(CallConnectionService.EXTRA_CALL_DATA, callData)
            putParcelable(
                TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE,
                PhoneAccountManager.getPhoneAccountHandle(context)
            )
        }

        try {
            telecomManager.placeCall(
                Uri.parse("tel:${callData.callerUid}"),
                extras
            )
        } catch (e: SecurityException) {
            Log.e(TAG, "Permission denied for outgoing call: ${e.message}")
        }
    }

    /**
     * Shows a high-priority notification as fallback when Telecom fails.
     * This notification has full-screen intent to show on lock screen.
     */
    private fun showFallbackNotification(context: Context, callData: CallData) {
        val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) 
            as NotificationManager

        // Create notification channel (required for Android 8.0+)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "Incoming Calls",
                NotificationManager.IMPORTANCE_HIGH  // High importance for call notifications
            ).apply {
                description = "Notifications for incoming calls"
                setSound(null, null)  // We'll play our own ringtone
                enableVibration(true)
            }
            notificationManager.createNotificationChannel(channel)
        }

        // Create accept action - launches call when tapped
        val acceptIntent = Intent(context, CallActionReceiver::class.java).apply {
            action = ACTION_ACCEPT_CALL
            putExtra(EXTRA_CALL_DATA, callData)
        }
        val acceptPendingIntent = PendingIntent.getBroadcast(
            context, 0, acceptIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        // Create reject action
        val rejectIntent = Intent(context, CallActionReceiver::class.java).apply {
            action = ACTION_REJECT_CALL
            putExtra(EXTRA_CALL_DATA, callData)
        }
        val rejectPendingIntent = PendingIntent.getBroadcast(
            context, 1, rejectIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        // Full screen intent - shows activity on lock screen
        val fullScreenIntent = Intent(context, IncomingCallActivity::class.java).apply {
            putExtra(EXTRA_CALL_DATA, callData)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        val fullScreenPendingIntent = PendingIntent.getActivity(
            context, 2, fullScreenIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        // Build the notification
        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_call)
            .setContentTitle("Incoming ${callData.callType} call")
            .setContentText("${callData.callerName} is calling...")
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setCategory(NotificationCompat.CATEGORY_CALL)  // Marks as call notification
            .setAutoCancel(true)
            .setOngoing(true)  // Can't be swiped away
            .setFullScreenIntent(fullScreenPendingIntent, true)  // Shows on lock screen
            .addAction(R.drawable.ic_call_end, "Decline", rejectPendingIntent)
            .addAction(R.drawable.ic_call_accept, "Accept", acceptPendingIntent)
            .build()

        notificationManager.notify(NOTIFICATION_ID, notification)
    }

    /**
     * Cancels the incoming call notification.
     * Call this when the call is answered, rejected, or cancelled.
     */
    fun cancelNotification(context: Context) {
        val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) 
            as NotificationManager
        notificationManager.cancel(NOTIFICATION_ID)
    }

    private const val TAG = "CallNotificationManager"
    private const val CHANNEL_ID = "incoming_calls"
    private const val NOTIFICATION_ID = 1001
    const val ACTION_ACCEPT_CALL = "action_accept_call"
    const val ACTION_REJECT_CALL = "action_reject_call"
    const val EXTRA_CALL_DATA = "extra_call_data"
}

Step 5: Create CallActionReceiver

This BroadcastReceiver handles the Accept and Decline button taps from the fallback notification.
/**
 * Handles notification button actions (Accept/Decline).
 * Used when the fallback notification is shown instead of system call UI.
 */
class CallActionReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        val callData = intent.getParcelableExtra<CallData>(
            CallNotificationManager.EXTRA_CALL_DATA
        ) ?: return

        when (intent.action) {
            CallNotificationManager.ACTION_ACCEPT_CALL -> {
                acceptCall(context, callData)
            }
            CallNotificationManager.ACTION_REJECT_CALL -> {
                rejectCall(context, callData)
            }
        }

        // Always cancel the notification after handling
        CallNotificationManager.cancelNotification(context)
    }

    private fun acceptCall(context: Context, callData: CallData) {
        // If we have an active Connection, use it
        CallConnectionHolder.getConnection()?.onAnswer()
            ?: run {
                // No Connection - accept directly via CometChat
                CometChat.acceptCall(callData.sessionId, object : CometChat.CallbackListener<Call>() {
                    override fun onSuccess(call: Call) {
                        // Launch call activity
                        val intent = Intent(context, CallActivity::class.java).apply {
                            flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            putExtra(CallActivity.EXTRA_SESSION_ID, callData.sessionId)
                            putExtra(CallActivity.EXTRA_CALL_TYPE, callData.callType)
                        }
                        context.startActivity(intent)
                    }
                    override fun onError(e: CometChatException) {
                        Log.e(TAG, "Accept failed: ${e.message}")
                    }
                })
            }
    }

    private fun rejectCall(context: Context, callData: CallData) {
        CallConnectionHolder.getConnection()?.onReject()
            ?: run {
                CometChat.rejectCall(
                    callData.sessionId,
                    CometChatConstants.CALL_STATUS_REJECTED,
                    object : CometChat.CallbackListener<Call>() {
                        override fun onSuccess(call: Call) {
                            Log.d(TAG, "Call rejected")
                        }
                        override fun onError(e: CometChatException) {
                            Log.e(TAG, "Reject failed: ${e.message}")
                        }
                    }
                )
            }
    }

    companion object {
        private const val TAG = "CallActionReceiver"
    }
}

Step 6: Create CallActivity

The CallActivity hosts the actual call UI using CometChat’s Calls SDK. It joins the call session and handles the call lifecycle.
/**
 * Activity that hosts the call UI.
 * Joins the CometChat call session and displays the video/audio interface.
 */
class CallActivity : AppCompatActivity() {

    private lateinit var callSession: CallSession
    private var sessionId: String? = null

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

        // Keep screen on during the call
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

        callSession = CallSession.getInstance()
        
        // Get call parameters from intent
        sessionId = intent.getStringExtra(EXTRA_SESSION_ID)
        val callType = intent.getStringExtra(EXTRA_CALL_TYPE) ?: "video"

        // Join the call session
        sessionId?.let { joinCallSession(it, callType) }

        // Listen for session end events
        setupSessionStatusListener()
    }

    /**
     * Joins the CometChat call session.
     * This connects to the actual audio/video call.
     */
    private fun joinCallSession(sessionId: String, callType: String) {
        val callContainer = findViewById<FrameLayout>(R.id.callContainer)

        // Configure session settings
        // See SessionSettingsBuilder for all options
        val sessionSettings = CometChatCalls.SessionSettingsBuilder()
            .setType(if (callType == "video") SessionType.VIDEO else SessionType.AUDIO)
            .build()

        // Join the session
        CometChatCalls.joinSession(
            sessionId = sessionId,
            sessionSettings = sessionSettings,
            view = callContainer,
            context = this,
            listener = object : CometChatCalls.CallbackListener<Void>() {
                override fun onSuccess(p0: Void?) {
                    Log.d(TAG, "Joined call session successfully")
                }

                override fun onError(exception: CometChatException) {
                    Log.e(TAG, "Failed to join call: ${exception.message}")
                    endCallAndFinish()
                }
            }
        )
    }

    /**
     * Listens for session status changes.
     * Ends the activity when the call ends.
     */
    private fun setupSessionStatusListener() {
        callSession.addSessionStatusListener(this, object : SessionStatusListener() {
            override fun onSessionLeft() {
                runOnUiThread { endCallAndFinish() }
            }

            override fun onConnectionClosed() {
                runOnUiThread { endCallAndFinish() }
            }
        })
    }

    /**
     * Properly ends the call and finishes the activity.
     */
    private fun endCallAndFinish() {
        // End the Connection (updates system call state)
        CallConnectionHolder.endCall()
        
        // Notify other participant via CometChat
        sessionId?.let { sid ->
            CometChat.endCall(sid, object : CometChat.CallbackListener<Call>() {
                override fun onSuccess(call: Call) {
                    Log.d(TAG, "Call ended successfully")
                }
                override fun onError(e: CometChatException) {
                    Log.e(TAG, "End call error: ${e.message}")
                }
            })
        }
        
        finish()
    }

    override fun onBackPressed() {
        // Prevent accidental back press during call
        // User must use the end call button
    }

    override fun onDestroy() {
        super.onDestroy()
        // Clean up if activity is destroyed while call is active
        if (callSession.isSessionActive) {
            callSession.leaveSession()
        }
    }

    companion object {
        private const val TAG = "CallActivity"
        const val EXTRA_SESSION_ID = "extra_session_id"
        const val EXTRA_CALL_TYPE = "extra_call_type"
        const val EXTRA_IS_INCOMING = "extra_is_incoming"
    }
}
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/callContainer"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#000000" />

Step 7: Configure AndroidManifest

Add all required permissions and component declarations to your AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Required Permissions -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    <uses-permission android:name="android.permission.VIBRATE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    
    <!-- VoIP-specific permissions -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application>
        
        <!-- Firebase Messaging Service for push notifications -->
        <service
            android:name=".CallFirebaseMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>

        <!-- ConnectionService for VoIP integration with Android Telecom -->
        <service
            android:name=".CallConnectionService"
            android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
            android:exported="true">
            <intent-filter>
                <action android:name="android.telecom.ConnectionService" />
            </intent-filter>
        </service>

        <!-- BroadcastReceiver for notification button actions -->
        <receiver
            android:name=".CallActionReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="action_accept_call" />
                <action android:name="action_reject_call" />
            </intent-filter>
        </receiver>

        <!-- Call Activity - shows on lock screen -->
        <activity
            android:name=".CallActivity"
            android:exported="false"
            android:launchMode="singleTask"
            android:showOnLockScreen="true"
            android:turnScreenOn="true"
            android:screenOrientation="portrait" />

        <!-- Incoming Call Activity (fallback for notification) -->
        <activity
            android:name=".IncomingCallActivity"
            android:exported="false"
            android:launchMode="singleTask"
            android:showOnLockScreen="true"
            android:turnScreenOn="true"
            android:excludeFromRecents="true"
            android:taskAffinity="" />

    </application>
</manifest>

Step 8: Initiate Outgoing Calls

To make an outgoing VoIP call, use the CometChat Chat SDK to initiate the call, then join the session:
/**
 * Initiates a VoIP call to another user.
 * 
 * @param receiverId The CometChat UID of the user to call
 * @param receiverName Display name of the receiver (for UI)
 * @param callType "audio" or "video"
 */
fun initiateVoIPCall(receiverId: String, receiverName: String, callType: String) {
    // Create a Call object with receiver info
    val call = Call(receiverId, CometChatConstants.RECEIVER_TYPE_USER, callType)

    // Initiate the call via CometChat Chat SDK
    // This sends a call notification to the receiver
    CometChat.initiateCall(call, object : CometChat.CallbackListener<Call>() {
        override fun onSuccess(call: Call) {
            Log.d(TAG, "Call initiated with sessionId: ${call.sessionId}")
            
            // Create CallData for the outgoing call
            val callData = CallData(
                sessionId = call.sessionId,
                callerName = receiverName,
                callerUid = receiverId,
                callType = callType,
                callerAvatar = null
            )
            
            // Option 1: Use Telecom system (shows system outgoing call UI)
            // CallNotificationManager.placeOutgoingCall(this@MainActivity, callData)
            
            // Option 2: Launch CallActivity directly (recommended)
            val intent = Intent(this@MainActivity, CallActivity::class.java).apply {
                putExtra(CallActivity.EXTRA_SESSION_ID, call.sessionId)
                putExtra(CallActivity.EXTRA_CALL_TYPE, callType)
                putExtra(CallActivity.EXTRA_IS_INCOMING, false)
            }
            startActivity(intent)
        }

        override fun onError(e: CometChatException) {
            Log.e(TAG, "Call initiation failed: ${e.message}")
            Toast.makeText(this@MainActivity, "Failed to start call", Toast.LENGTH_SHORT).show()
        }
    })
}

// Usage example:
// initiateVoIPCall("user123", "John Doe", CometChatConstants.CALL_TYPE_VIDEO)

Complete Flow Diagram

This diagram shows the complete flow for both incoming and outgoing VoIP calls:

Troubleshooting

IssueSolution
System call UI not showingEnsure PhoneAccount is registered and enabled. Check Settings > Apps > Phone > Calling accounts
Calls not received in backgroundVerify FCM configuration and ensure high-priority notifications are enabled
Permission denied errorsRequest MANAGE_OWN_CALLS permission at runtime on Android 11+
Call drops immediatelyVerify CometChat authentication is valid before joining session
Audio routing issuesEnsure audioModeIsVoip = true is set on the Connection
PhoneAccount not enabledCall PhoneAccountManager.openPhoneAccountSettings() to let user enable it
private val requiredPermissions = arrayOf(
    Manifest.permission.CAMERA,
    Manifest.permission.RECORD_AUDIO,
    Manifest.permission.BLUETOOTH_CONNECT,  // Android 12+
    Manifest.permission.POST_NOTIFICATIONS   // Android 13+
)

private fun checkAndRequestPermissions() {
    val permissionsToRequest = requiredPermissions.filter {
        ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
    }
    
    if (permissionsToRequest.isNotEmpty()) {
        ActivityCompat.requestPermissions(
            this,
            permissionsToRequest.toTypedArray(),
            PERMISSION_REQUEST_CODE
        )
    }
}

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == PERMISSION_REQUEST_CODE) {
        val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
        if (!allGranted) {
            // Show explanation or disable calling features
            Toast.makeText(this, "Permissions required for calling", Toast.LENGTH_LONG).show()
        }
    }
}

companion object {
    private const val PERMISSION_REQUEST_CODE = 100
}