# Use native Android call UI to join a Zoom session The [ConnectionService API](https://developer.android.com/reference/android/telecom/ConnectionService) 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](https://github.com/zoom/videosdk-android-kotlin-quickstart). ## Set up `KeepAliveService` The app's landing page collects the session name, user name, optional password, and session JWT token. ![](/img/blog/ticorrianheard/connectionserviceform.png) 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: ```kotlin 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: ```xml ``` ## 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. ```kotlin fun setParams(context: Context, zoomViewModel: ZoomSessionViewModel){ this.context = context this.zoomViewModel = zoomViewModel } ``` Register a Telecom account with the Phone app: ```kotlin 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: ```kotlin 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: ```kotlin 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: ```kotlin 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: ```kotlin 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: ```xml ``` ## 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. ```kotlin 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()`: ```kotlin 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`: ```xml ``` 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: ```bash adb shell am broadcast -a com.videosdkconnectionservice.ACTION_START_CALL -n com.videosdkconnectionservice/com.videosdkconnectionservice.receivers.StartCallReceiver ``` 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](https://github.com/zoom/videosdk-android-kotlin-quickstart) and the [Android Sample Apps](/docs/video-sdk/android/sample-apps/) section in the marketplace docs.