Use native Android call UI to join a Zoom session

The ConnectionService API allows third-party apps to integrate with the native call UI. With it, users can join Zoom Sessions directly from the phone app's familiar PSTN-style flow.

Prerequisites

  • JDK 17 (required by build.gradle.kts)
  • Android SDK:
    • minSdk: API 37 (Android 17)
    • targetSdk: API 37 (Android 17)
    • compileSdk: API 37
  • Android Studio (latest stable)
  • Gradle (included via Gradle wrapper)
  • ADB CLI tool

The code for this blog post is based on the Zoom Video SDK for Android Kotlin Quickstart.

Set up KeepAliveService

The app's landing page collects the session name, user name, optional password, and session JWT token.

This sample starts a foreground service when the user clicks the "Register Session" button so the process can continue listening for an incoming call event while the app is in the background.

Here is the service:

class KeepAliveService : Service() {
    private val CHANNEL_ID = "keepalive_channel"
    override fun onBind(intent: Intent?): IBinder? = null
    override fun onCreate() {
        super.onCreate()
        createNotificationChannel()
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("VideoSDK Connection Service")
            .setContentText("Waiting for incoming session push notification")
            .setSmallIcon(R.mipmap.ic_launcher)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .build()
        startForeground(1001, notification)
    }
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // Keep service running; no extra work here
        return START_STICKY
    }
    override fun onDestroy() {
        super.onDestroy()
        stopForeground(true)
    }
    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val name = "Keepalive"
            val descriptionText = "Keep app alive to receive scheduled session pushes"
            val importance = NotificationManager.IMPORTANCE_LOW
            val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
                description = descriptionText
            }
            val notificationManager: NotificationManager = getSystemService(NotificationManager::class.java)
            notificationManager.createNotificationChannel(channel)
        }
    }
}

In AndroidManifest.xml, add the service and the foreground-service permission:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
        tools:ignore="ForegroundServicesPolicy" />
        <!-- needed for app to run in the background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<service
    android:name=".services.KeepAliveService"
    android:exported="false"
    android:foregroundServiceType="dataSync" />

Set up ConnectionService

Create a class that extends ConnectionService. It handles incoming and outgoing calls in VoIP apps and lets us use the native call UI to join Zoom Sessions. When you instantiate it, pass in the application context and a ZoomSessionViewModel so it can call into the Zoom Video SDK.

fun setParams(context: Context, zoomViewModel: ZoomSessionViewModel){
        this.context = context
        this.zoomViewModel = zoomViewModel
    }

Register a Telecom account with the Phone app:

fun registerTelecomAccount() {
        if (!::context.isInitialized) {
            pp("Error:" + "Context is missing!")
            return
        }
        val telecomManager = context.getSystemService(TELECOM_SERVICE) as TelecomManager
        val componentName = ComponentName(context, MyConnectionService::class.java)
        phoneAccountHandle = PhoneAccountHandle(componentName, context.packageName)
        val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "My VoIP App")
            .setCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER or PhoneAccount.CAPABILITY_VIDEO_CALLING)
            .addSupportedUriScheme(PhoneAccount.SCHEME_TEL)
            .build()
        telecomManager.registerPhoneAccount(phoneAccount)
        pp("Phone account registered successfully")
    }

When the app is running, go to Phone > Settings > Calls > Calling Accounts and enable the My VoIP App account so it can receive calls.

Add logic to trigger a new incoming call using the TelecomManager API. The native call UI will show the Zoom Session Name as the incoming call identifier:

fun addNewIncomingCall(
        phoneNumberOrCallID: String = "Zoom Session",
    ) {
        if (!this::context.isInitialized || !this::phoneAccountHandle.isInitialized) {
            pp("Error: Context and phoneAccountHandle must be initialized. Call setParams() and registerTelecomAccount() first.")
            return
        }
        val telecomManager = context.getSystemService(TELECOM_SERVICE) as TelecomManager
        val extras = Bundle().apply {
            val uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, phoneNumberOrCallID, null)
            putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, uri)
            putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
        }
        try {
            telecomManager.addNewIncomingCall(phoneAccountHandle, extras)
            pp("System-managed incoming call flow triggered - onCreateIncomingConnection will be called")
        } catch (e: SecurityException) {
            pp("SecurityException: Unable to trigger incoming call: ${e.message}")
            e.printStackTrace()
        }
    }

When this function runs, onCreateIncomingConnection is triggered and handles the incoming call:

  override fun onCreateIncomingConnection(
        handle: PhoneAccountHandle,
        request: ConnectionRequest
    ): Connection {
        // Try to use the initialized singleton instance if this instance isn't initialized
        val serviceInstance = if (!this::zoomViewModel.isInitialized) {
            val singletonInstance = getInstance()
            if (singletonInstance::zoomViewModel.isInitialized) {
                singletonInstance
            } else {
                pp("Error: zoomViewModel has not been initialized on any service instance.")
                throw IllegalStateException("zoomViewModel must be initialized before creating incoming connection")
            }
        } else {
            this
        }
        val connection = ZoomVoIPConnection(serviceInstance.zoomViewModel)
        connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
        connection.initializingCall()
        return connection
    }

The ZoomVoIPConnection class extends the Connection class. It handles call state and actions such as answering, rejecting, muting, and disconnecting the call:

class ZoomVoIPConnection(
    private val zoomViewModel: ZoomSessionViewModel
) : Connection() {
    fun initializingCall() {
        connectionCapabilities = CAPABILITY_MUTE or CAPABILITY_SUPPORT_HOLD or CAPABILITY_HOLD
        audioModeIsVoip = true
        setInitializing()
    }
    fun disconnect() {
        setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
        destroy()
        zoomViewModel.closeSession(end = true)
    }
    override fun onAnswer() {
        super.onAnswer()
        setActive()
        zoomViewModel.joinSession(this)
    }
    override fun onReject() {
        super.onReject()
        setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
        destroy()
    }
    override fun onDisconnect() {
        super.onDisconnect()
        setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
        destroy()
        zoomViewModel.closeSession(end = true)
    }
    override fun onMuteStateChanged(isMuted: Boolean) {
        zoomViewModel.setMicrophoneMuted(isMuted)
    }
}

ZoomSessionViewModel handles the Zoom Video SDK logic. Answering the call invokes joinSession, rejecting it ends the Telecom connection, and disconnecting it invokes closeSession. A mute-state change invokes setMicrophoneMuted with the state reported by the native call UI.

In the ViewModel, the call flow looks like this:

    private lateinit var voipconnection: ZoomVoIPConnection
    private var connectionService: MyConnectionService? = null
    private var sdkInitialized: Boolean = false
    fun initZoomSDK (config: Config) {
        /* SDK Initialization Logic */
        this.connectionService = MyConnectionService.getInstance()
        this.connectionService?.setParams(context, this)
        val listener = EventListener(this).listener
        ZoomVideoSDK.getInstance().addListener(listener)
    }
    fun startCall() {
        val cs = MyConnectionService.getInstance()
        cs.registerTelecomAccount()
        cs.addNewIncomingCall(config.sessionName)
    }
    fun joinSession(voipConnection: ZoomVoIPConnection) {
        //store voip connection for manual use in call
        voipconnection = voipConnection
        if (!sdkInitialized)  {
            pp("Error: SDK not initialized. Cannot join session.")
            voipconnection.disconnect()
            return
        }
        /* SDK Join Session Logic */
    }
    fun setMicrophoneMuted(isMuted: Boolean) {
        val user = ZoomVideoSDK.getInstance().session.mySelf
        val audioHelper = ZoomVideoSDK.getInstance().audioHelper
        if (isMuted) {
            audioHelper.muteAudio(user)
        } else {
            audioHelper.unMuteAudio(user)
        }
    }
    fun disconnectCall() {
        voipconnection.disconnect()
    }

In AndroidManifest.xml, add the ConnectionService entry and the required permissions:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<service
    android:name=".viewmodel.MyConnectionService"
    android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
    android:foregroundServiceType="phoneCall"
    android:exported="true">
    <intent-filter>
        <action android:name="android.telecom.ConnectionService" />
    </intent-filter>
</service>

Set up the BroadcastReceiver

The app can now receive incoming calls and activate the native call UI. Add a BroadcastReceiver to simulate a push notification that triggers the call flow.

Create a class that extends BroadcastReceiver to handle incoming broadcasts.

class StartCallReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        pp("StartCallReceiver - Received broadcast: ${intent?.action}")
        if (intent?.action == "com.videosdkconnectionservice.ACTION_START_CALL") {
            pp("Starting Call from BroadcastReceiver...")
            val service = MyConnectionService.getInstance()
            service.triggerStartCall()
            return
        }
    }
}

The receiver calls triggerStartCall in MyConnectionService, which invokes startCall():

fun triggerStartCall() {
        if (!this::zoomViewModel.isInitialized) {
            pp("Cannot trigger startCall: zoomViewModel not initialized")
            return
        }
        try {
            zoomViewModel.startCall()
        } catch (e: Exception) {
            pp("Error triggering startCall: ${e.message}")
        }
    }

Register the BroadcastReceiver in AndroidManifest.xml:

<receiver
    android:name="com.videosdkconnectionservice.receivers.StartCallReceiver"
    android:exported="true"
    tools:ignore="ExportedReceiver">
    <intent-filter>
        <action android:name="com.videosdkconnectionservice.ACTION_START_CALL" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
  </receiver>

The app can now simulate receiving a push notification and trigger the native call UI. A production app can use a push notification service such as Firebase Cloud Messaging (FCM) to receive incoming call notifications.

Test the app

Run the app and enter the session name, user name, optional password, and session JWT token. Click "Register Session" to start listening for incoming push notifications.

From the Android Studio terminal, run:

adb shell am broadcast -a com.videosdkconnectionservice.ACTION_START_CALL -n com.videosdkconnectionservice/com.videosdkconnectionservice.receivers.StartCallReceiver

<video src="/img/blog/ticorrianheard/connectionservicevideo.mp4" autoPlay="true" loop muted width={720} height={405} style={{ borderRadius: "12px", height: "auto", aspectRatio: "auto" }}

When the broadcast is received, the native call UI appears and you can answer, reject, or disconnect the call from there.

For more examples and starting points, see the Zoom Video SDK GitHub and the Android Sample Apps section in the marketplace docs.