ANDROID • KOTLIN • STEP-BY-STEP

How to add voice calling to an Android app (Kotlin)

Build a one-to-one voice call in a native Android app with the RTCExpress voice calling SDK from Maven Central. In about 15 minutes you will have two Android phones calling each other, with accept, reject, mute and hang up, and no WebRTC, TURN or signaling servers to run yourself.

Works on the Free planFree projects include 1:1 voice calls with 2,000 voice participant minutes a month, so you can follow this tutorial without a paid plan. Create a free project to get an App ID.

1. What you need

  • Android Studio with JDK 17.
  • Two Android devices, or one Android phone and an Android emulator, on Android 7.0 (API 24) or newer.
  • A free RTCExpress project. Its App ID is in the customer console.

2. Create a project and add the SDK

In Android Studio choose New Project → Empty Views Activity, language Kotlin, minimum SDK API 24. Name it VoiceCallDemo with the package com.example.voicecalldemo.

The SDK is published on Maven Central as com.devsquarelabs:rtcexpress-android. New projects already list mavenCentral() in settings.gradle.kts. Open app/build.gradle.kts, keep what the template generated, and make sure these lines are there:

android {
    defaultConfig {
        minSdk = 24
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {
    implementation("com.devsquarelabs:rtcexpress-android:0.1.0")
}

Click Sync Now. The SDK's manifest already declares the internet and microphone permissions, so you don't edit AndroidManifest.xml. Your app only has to ask the user for microphone access at runtime, which the code below does.

3. Create two test tokens

Log in to your customer console → Token tools and create two short-lived tokens in the same project and room:

  • User ID alice, room ID voice-test.
  • User ID bob, room ID voice-test.

Create Config.kt next to MainActivity.kt and paste in your App ID and both tokens:

package com.example.voicecalldemo

// Test values from the console's Token tools.
// In a real app, fetch the token from your own backend. Never ship an App Secret.
const val SERVER_URL = "https://api.devsquarelabs.com"
const val APP_ID = "YOUR_APP_ID"
const val ROOM_ID = "voice-test"
val TOKENS = mapOf(
    "alice" to "ALICE_TOKEN",
    "bob" to "BOB_TOKEN",
)
Use user tokens, never your App SecretTest tokens expire, which is what you want. Your App Secret must never be built into an Android app, because anyone can extract it from the APK.

4. Write the call screen

Replace MainActivity.kt with this. It is the whole app: one screen where you pick a user, connect, join a room, and place or answer a call. It builds its views in code, so you can leave the generated layout file alone.

package com.example.voicecalldemo

import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.rtcexpress.sdk.CallInvite
import com.rtcexpress.sdk.CallStateUpdate
import com.rtcexpress.sdk.RTCExpress
import com.rtcexpress.sdk.RTCInitOptions
import java.util.concurrent.Executors

class MainActivity : AppCompatActivity(), RTCExpress.Listener {
    // The SDK does network work, so call it from a background thread.
    private val worker = Executors.newSingleThreadExecutor()
    private lateinit var rtc: RTCExpress
    private lateinit var status: TextView
    private lateinit var buttons: LinearLayout

    private var me: String? = null
    private var pendingUser: String? = null
    private var joined = false
    private var incoming = false
    private var inCall = false
    private var muted = false
    private val peer get() = if (me == "alice") "bob" else "alice"

    private val askForMicrophone =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            val user = pendingUser ?: return@registerForActivityResult
            if (granted) connect(user) else status.text = "Allow microphone access to make calls"
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        rtc = RTCExpress(applicationContext)
        rtc.setListener(this)

        status = TextView(this).apply { text = "Pick a user"; textSize = 18f }
        buttons = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
        setContentView(LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(48, 96, 48, 48)
            addView(status)
            addView(buttons)
        })
        render()
    }

    private fun pickUser(user: String) {
        pendingUser = user
        val permission = Manifest.permission.RECORD_AUDIO
        if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
            connect(user)
        } else {
            askForMicrophone.launch(permission)
        }
    }

    private fun connect(user: String) {
        me = user
        status.text = "Connecting..."
        render()
        sdk {
            rtc.init(
                RTCInitOptions(
                    serverUrl = SERVER_URL,
                    appId = APP_ID,
                    userId = user,
                    token = TOKENS.getValue(user),
                    mediaMode = "p2p", // 1:1 calls on the Free plan are peer-to-peer
                )
            )
            rtc.joinRoom(ROOM_ID)
        }
    }

    // Runs an SDK call on the background thread and shows any error.
    private fun sdk(block: () -> Unit) = worker.execute {
        try {
            block()
        } catch (e: Exception) {
            runOnUiThread { status.text = "Error: ${e.message}" }
        }
    }

    private fun toggleMute() {
        muted = !muted
        sdk { rtc.muteMicrophone(muted) }
        render()
    }

    private fun render() {
        buttons.removeAllViews()
        fun button(label: String, onClick: () -> Unit) =
            buttons.addView(Button(this).apply { text = label; setOnClickListener { onClick() } })

        when {
            me == null -> {
                button("I am Alice") { pickUser("alice") }
                button("I am Bob") { pickUser("bob") }
            }
            incoming -> {
                button("Accept") { sdk { rtc.acceptCall() } }
                button("Reject") { sdk { rtc.rejectCall() } }
            }
            inCall -> {
                button(if (muted) "Unmute" else "Mute") { toggleMute() }
                button("End call") { sdk { rtc.endCall() } }
            }
            joined -> button("Call $peer") { sdk { rtc.callUser(peer) } }
        }
    }

    // SDK events arrive on a background thread, so switch to the UI thread first.
    override fun onRoomJoined(roomId: String, members: List<String>) = runOnUiThread {
        joined = true
        status.text = "Joined $roomId"
        render()
    }

    override fun onCallInvite(invite: CallInvite) = runOnUiThread {
        incoming = true
        render()
    }

    override fun onCallState(update: CallStateUpdate) = runOnUiThread {
        status.text = "Call ${update.state}"
        inCall = update.state in setOf("ringing", "connecting", "connected")
        if (update.state != "ringing") incoming = false
        if (!inCall) muted = false
        render()
    }

    override fun onError(message: String) = runOnUiThread {
        status.text = "Error: $message"
    }

    override fun onDestroy() {
        worker.execute { rtc.destroy() }
        worker.shutdown()
        super.onDestroy()
    }
}

How it works

  • init connects to RTCExpress with a short-lived user token. mediaMode = "p2p" sends audio directly between the two phones, through a hosted TURN relay when a direct path isn't possible. This is how 1:1 calls work on the Free plan.
  • joinRoom asks to join. Membership is confirmed by onRoomJoined, so the Call button only appears after it fires.
  • callUser rings the other user. On their phone, onCallInvite fires and they can acceptCall or rejectCall.
  • onCallState reports ringing, connecting, connected, rejected and ended, which drive the buttons.
  • Threads: init makes network requests, so SDK calls run on a single background executor. SDK events arrive on a background thread too, so each listener switches to the UI thread with runOnUiThread.
  • destroy runs in onDestroy. It ends any call and closes the connection.

5. Run it on two devices

Connect both devices and select each one in turn in Android Studio's device menu, then press Run. From the command line, build once and install on both:

./gradlew installDebug

With two devices connected, installDebug installs on both.

  1. On the first device press I am Alice, and on the second press I am Bob. Allow microphone access and wait for Joined voice-test.
  2. On Alice's device, press Call bob.
  3. On Bob's device, press Accept.
  4. Both screens should show Call connected. Talk, then try Mute and End call.

Two phones next to each other will echo; use headphones, or move apart. Want to hear the quality first without building anything? Open the live demo on your phone and laptop.

Troubleshooting

  • Gradle can't find rtcexpress-android: check that mavenCentral() is in the repositories block of settings.gradle.kts, then sync again.
  • Build fails with a Java or JVM target version error: set Java 17 in compileOptions and jvmTarget = "17" as shown in step 2.
  • Error after connecting: check the App ID, that each device uses its own user, and that the tokens haven't expired. Create new tokens if in doubt.
  • Call button never appears: both tokens must be for the same room, voice-test.
  • No incoming call: the user IDs in the tokens must be exactly alice and bob, and both devices must be connected.
  • Connected but no sound: check microphone permission in Android settings and the media volume. Emulators need the host microphone enabled in their extended controls.

6. Before you ship

Replace Config.kt with a call to your own backend. Your server signs the user in, checks they may join the room, and requests a short-lived token from POST https://api.devsquarelabs.com/v1/token with your App ID and App Secret. The App Secret stays on the server.

Then test on real devices across Wi-Fi and mobile data, with denied permissions, rejected calls, expired tokens and an app in the background. This tutorial shows foreground 1:1 voice calling; ringing a phone while the app is closed needs push notifications and an incoming-call notification (see setIncomingCallNotifier in the Android SDK guide).

Android voice calling questions

Do I need to run my own WebRTC, TURN or signaling server?

No. RTCExpress hosts signaling, TURN relays and media servers. The Android SDK connects to them with the token your backend issues.

Can I add video to the same app?

Yes. Call rtc.callUser(peer, video = true), ask for camera permission too, and render the remote track from onRemoteVideo. Video calling is included in the Pro plan.

Does it work with Java?

Yes. The SDK is written in Kotlin but its classes can be called from Java. This tutorial uses Kotlin because it is the default for new Android projects.

How are voice minutes counted?

In participant minutes: a two-person call lasting ten minutes uses twenty. The Free plan includes 2,000 a month and Starter includes 30,000. See pricing.