Ringlr is a Kotlin Multiplatform Mobile (KMM) library for handling phone calls and audio configuration across Android and iOS. It provides a single unified API on top of Android's Telecom framework and iOS's CallKit.
- Features
- Upcoming Changes
- Contribution Guidelines
- Permissions
- Implementation Guide
- CallManager API Reference
- Project Structure
- Important Notes
- Troubleshooting
- Building the Project
- Make and manage outgoing phone calls
- Answer incoming calls (via system UI)
- Mute, hold, and end active calls
- Route audio to speaker or earpiece; iOS auto-routes to connected Bluetooth/wired headset
- Request and check call and microphone permissions
- Real-time call state callbacks
- Android Telecom framework integration
- iOS CallKit integration
Ringlr provides the UI and call lifecycle layer for VoIP/SIP calls — it does not bundle a SIP stack or media engine.
| Layer | Ringlr's role | Your responsibility |
|---|---|---|
| System call UI (CallKit / Telecom) | Handled by Ringlr | — |
| Call state (dialing, active, hold, end) | Handled by Ringlr | — |
| SIP signaling (REGISTER, INVITE, BYE) | Not included | Your SIP stack |
| Media / audio (RTP, codecs, SRTP) | Not included | Your SIP stack |
Android removed its built-in SipManager API in Android 12 (API 31) with no official replacement.
On API ≤ 30, Ringlr uses the system SipManager automatically after configureSipAccount().
On API 31+, configureSipAccount() returns CallError.SipUnsupported. The recommended flow:
- Establish your session with a SIP/VoIP library (Linphone, PJSIP, WebRTC, etc.)
- Once connected, call
startOutgoingCall(number, displayName, scheme = "sip")so Ringlr registers the call with the Telecom framework and shows system UI
CallKit is transport-agnostic — it shows the system call UI regardless of the underlying protocol. configureSipAccount() on iOS stores the profile for your use; CallKit handles the UI automatically via startOutgoingCall(scheme = "sip").
- VoIP — any voice call over the internet (WhatsApp, FaceTime, Zoom are all VoIP)
- SIP — one open protocol for VoIP (what
scheme = "sip"targets) - WebRTC — another common VoIP protocol; not covered by the SIP scheme
VoIP push wakes the app for an incoming call even when it is suspended. Without it, iOS will not display the incoming call screen reliably.
val registrar = VoipPushRegistrar(platformConfig)
registrar.register(object : VoipPushListener {
override fun onTokenRefreshed(token: String) {
// Send token to your push server
}
override fun onIncomingCall(payload: VoipPushPayload) {
// CallKit system UI is already shown by Ringlr.
// Use payload for your own in-app handling if needed.
}
})PushKit delivers the push directly to the delegate. Ringlr calls reportIncomingCall on CallKit automatically — no extra steps needed.
Expected push payload keys from your server:
| Key | Value |
|---|---|
call_id |
Unique call identifier |
caller_number |
Phone number or SIP address |
caller_name |
Display name |
scheme |
"sip" or "tel" |
// In Application.onCreate
val registrar = VoipPushRegistrar(platformConfig)
registrar.register(object : VoipPushListener {
override fun onTokenRefreshed(token: String) { sendToServer(token) }
override fun onIncomingCall(payload: VoipPushPayload) { /* show in-app UI */ }
})
// In your FirebaseMessagingService
override fun onNewToken(token: String) {
registrar.handleTokenRefresh(token)
}
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["type"] == "voip") {
registrar.handleVoipPush(
VoipPushPayload(
callId = message.data["call_id"] ?: "",
callerNumber = message.data["caller_number"] ?: "",
callerName = message.data["caller_name"] ?: "Unknown",
scheme = message.data["scheme"] ?: "sip"
)
)
}
}Ringlr notifies onIncomingCall and registers the call with the Telecom framework for the system call log. Add MANAGE_OWN_CALLS to your manifest for the system call screen to appear.
- ✨ Custom in-app calling UI
- ✨ Publishing on Maven Central
Declare these in your AndroidManifest.xml:
| Permission | Purpose |
|---|---|
ANSWER_PHONE_CALLS |
Answer incoming calls programmatically |
CALL_PHONE |
Place outgoing calls |
READ_PHONE_STATE |
Read active call state |
MANAGE_OWN_CALLS |
Register a custom phone account |
READ_PHONE_NUMBERS |
Read the device's phone number |
MODIFY_AUDIO_SETTINGS |
Switch audio routes |
RECORD_AUDIO |
Microphone access during calls |
BLUETOOTH / BLUETOOTH_CONNECT |
Bluetooth headset support |
Add these keys to your Info.plist:
| Key | Purpose |
|---|---|
NSMicrophoneUsageDescription |
Microphone access during calls |
NSBluetoothAlwaysUsageDescription |
Bluetooth headset support |
iOS call access (CallKit) is managed by the system — no runtime CALL_PHONE equivalent is required.
CallManager is the single entry point for all call operations.
expect class CallManager(configuration: PlatformConfiguration) : CallManagerInterfacesuspend fun startOutgoingCall(
number: String,
displayName: String,
scheme: String = "tel" // "tel" for PSTN, "sip" for VoIP
): CallResult<Call>
suspend fun endCall(callId: String): CallResult<Unit>suspend fun muteCall(callId: String, muted: Boolean): CallResult<Unit>
suspend fun holdCall(callId: String, onHold: Boolean): CallResult<Unit>suspend fun getCallState(callId: String): CallResult<CallState>
suspend fun getActiveCalls(): CallResult<List<Call>>suspend fun setAudioRoute(route: AudioRoute): CallResult<Unit>
suspend fun getCurrentAudioRoute(): CallResult<AudioRoute>AudioRoute values: SPEAKER, EARPIECE, BLUETOOTH, WIRED_HEADSET
iOS note:
SPEAKERandEARPIECEare controlled viaAVAudioSessionport override. ForBLUETOOTHandWIRED_HEADSET, iOS automatically routes to any connected device when the speaker override is removed — no additional API call is required. UseAVRoutePickerViewfor fine-grained Bluetooth device selection.
fun registerCallStateCallback(callback: CallStateCallback)
fun unregisterCallStateCallback(callback: CallStateCallback)CallStateCallback events: onCallStateChanged, onCallAdded, onCallRemoved
Every operation returns CallResult<T> — either Success(data) or Error(callError). No exceptions leak through the API boundary.
when (val result = callManager.startOutgoingCall("+1234567890", "John Doe")) {
is CallResult.Success -> println("Call started: ${result.data.id}")
is CallResult.Error -> println("Failed: ${result.error}")
}shared/
└── src/
├── commonMain/ # Shared interfaces, models, expect declarations
│ └── call/
│ ├── CallHandler.kt # expect PlatformConfiguration + CallManager
│ ├── CallManagerInterface.kt
│ ├── Call.kt
│ ├── CallState.kt
│ ├── CallResult.kt
│ ├── CallError.kt
│ ├── AudioRoute.kt
│ └── CallStateCallback.kt
├── androidMain/ # Android Telecom implementation
│ └── call/
│ ├── CallHandler.android.kt
│ ├── TelecomConnectionService.kt
│ └── PhoneAccountRegistrar.kt
└── iosMain/ # iOS CallKit implementation
└── call/
├── CallHandler.ios.kt
└── callkit/
├── ActiveCallRegistry.kt # Tracks Call ↔ NSUUID
├── SystemCallBridge.kt # CXProviderDelegate
├── CallActionDispatcher.kt # Submits CXTransactions
└── CallAudioRouter.kt # AVAudioSession speaker/earpiece override; iOS routes Bluetooth/wired automatically
Ringlr is available as a local Gradle module.
- Clone the repository:
git clone https://github.com/Rohit-554/Ringlr.git-
Copy the
shareddirectory into your project root. -
Add to
settings.gradle.kts:
include(":shared")- Add the dependency in your app's
build.gradle.kts:
dependencies {
implementation(project(":shared"))
}- Sync Gradle.
Create an Application class and call PlatformConfiguration.init:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
PlatformConfiguration.init(this)
}
}Register it in AndroidManifest.xml along with the ConnectionService:
<application android:name=".MyApplication">
<service
android:name=".ringlr.callHandler.CallConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
</application>No Application subclass is needed. Create and initialise PlatformConfiguration directly in your Compose entry point:
val platformConfig = PlatformConfiguration.create()
platformConfig.initializeCallConfiguration()Make sure NSMicrophoneUsageDescription and NSBluetoothAlwaysUsageDescription are set in Info.plist.
@Composable
fun App() {
val factory = rememberPermissionsControllerFactory()
val controller = remember(factory) { factory.createPermissionsController() }
BindEffect(controller)
// ... your UI
}Requesting a permission:
scope.launch {
when (controller.getPermissionState(Permission.CALL_PHONE)) {
PermissionState.Granted -> placeCall()
PermissionState.DeniedAlways -> showOpenSettingsPrompt()
else -> {
try {
controller.providePermission(Permission.CALL_PHONE)
placeCall()
} catch (e: DeniedAlwaysException) {
showOpenSettingsPrompt()
} catch (e: DeniedException) {
showPermissionRationale()
}
}
}
}Available permissions: Permission.CALL_PHONE, Permission.MICROPHONE, Permission.BLUETOOTH, Permission.BLUETOOTH_CONNECT, Permission.RECORD_AUDIO, and more.
val platformConfig = PlatformConfiguration.create()
platformConfig.initializeCallConfiguration()
val callManager = CallManager(platformConfig)
val result = callManager.startOutgoingCall(
number = "+1234567890",
displayName = "John Doe"
)
when (result) {
is CallResult.Success -> {
val call = result.data
// register a callback, mute, hold, end, etc.
}
is CallResult.Error -> {
// handle result.error
}
}@Composable
@Preview
fun App() {
val platformConfig = remember {
PlatformConfiguration.create().also { it.initializeCallConfiguration() }
}
val callManager = remember { CallManager(platformConfig) }
val scope = rememberCoroutineScope()
val factory = rememberPermissionsControllerFactory()
val controller = remember(factory) { factory.createPermissionsController() }
BindEffect(controller)
MaterialTheme {
Scaffold(Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
var phoneNumber by remember { mutableStateOf("") }
TextField(
value = phoneNumber,
onValueChange = { phoneNumber = it },
label = { Text("Enter phone number") },
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
Button(
onClick = {
scope.launch {
try {
controller.providePermission(Permission.CALL_PHONE)
callManager.startOutgoingCall(phoneNumber, "Call from KMM")
} catch (e: DeniedAlwaysException) {
controller.openAppSettings()
} catch (e: DeniedException) {
// show rationale
}
}
},
modifier = Modifier.padding(16.dp)
) {
Text("Call")
}
}
}
}
}- Always call
initializeCallConfiguration()before constructingCallManager - Call
cleanupCallConfiguration()when the calling session ends (e.g.,onDestroy) - On Android, register
TelecomConnectionServicein your manifest — calls will silently fail otherwise - On iOS, the
CXProvideris a singleton per app;PlatformConfigurationowns it and must not be recreated per call - Always unregister callbacks to avoid memory leaks
| Problem | Solution |
|---|---|
| Call never starts on Android | Check TelecomConnectionService is declared in the manifest with the correct permission |
| Permission permanently denied | Call controller.openAppSettings() to redirect the user |
| Audio routes back to earpiece after Bluetooth connects | Re-call setAudioRoute(AudioRoute.BLUETOOTH) after onCallStateChanged fires ACTIVE |
| iOS call does not show system call UI | Ensure PlatformConfiguration.initializeCallConfiguration() was called before placing the call |
CallResult.Error(CallNotFound) |
The callId was not found — use the id from the Call returned by startOutgoingCall |
./gradlew build