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.
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",
)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,
onCallInvitefires and they can acceptCall or rejectCall. - onCallState reports
ringing,connecting,connected,rejectedandended, which drive the buttons. - Threads:
initmakes 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 withrunOnUiThread. - 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 installDebugWith two devices connected, installDebug installs on both.
- 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.
- On Alice's device, press Call bob.
- On Bob's device, press Accept.
- 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 therepositoriesblock ofsettings.gradle.kts, then sync again. - Build fails with a Java or JVM target version error: set Java 17 in
compileOptionsandjvmTarget = "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
aliceandbob, 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.