◉ Ringlr
A unified call-handling library for Android and iOS.
One API. Android Telecom. iOS CallKit.
Introduction
Ringlr is a Kotlin Multiplatform Mobile (KMM) library that gives you a single, consistent API for placing, receiving, and managing phone calls on both Android and iOS.
Under the hood it bridges Android's Telecom framework and iOS's CallKit, so your shared call logic never forks into platform-specific branches.
Android
Telecom framework, ConnectionService, audio routing via AudioManager, FCM push
iOS
CallKit, CXProvider, AVAudioSession, PushKit VoIP push
Features
Place PSTN and SIP calls; answer or decline incoming calls via Ringlr or the system UI.
Toggle mute and hold on any active call with a single suspend call.
Switch between speaker, earpiece, Bluetooth, and wired headset at runtime.
Register CallStateCallback to react to call lifecycle events as they happen.
System-UI integration for SIP and VoIP calls. Bring your own media stack.
PushKit on iOS; FCM bridge on Android — wake the app for incoming calls.
Unified permission controller for microphone, call, and Bluetooth across platforms.
Ready-to-use Compose Multiplatform CallScreen with dialer, active call panel, and incoming call screen.
Architecture
Ringlr follows the KMM expect/actual pattern.
The shared module declares expect class CallManager and
expect class PlatformConfiguration; each platform provides its
actual implementation.
Setup
Ringlr is currently distributed as a local Gradle module. Maven Central publishing is coming soon.
1. Clone the repository
git clone https://github.com/Rohit-554/Ringlr.git
2. Copy the shared module into your project
Place the shared directory at the root of your project alongside your app module.
3. Register the module
In your project's settings.gradle.kts:
include(":shared")
4. Add the dependency
In your app module's build.gradle.kts:
dependencies {
implementation(project(":shared"))
}
5. Sync Gradle
./gradlew build
Android Initialization
Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
PlatformConfiguration.init(this)
}
}
AndroidManifest.xml
<application android:name=".MyApplication">
<service
android:name="io.jadu.ringlr.call.TelecomConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
</application>
TelecomConnectionService declaration, call placement will silently fail on Android.
Compose entry point
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val configuration = PlatformConfiguration.create()
setContent {
App(configuration = configuration)
}
}
}
iOS Initialization
No Application subclass is needed on iOS.
Create and initialise PlatformConfiguration in your Compose entry point.
The CXProvider is a singleton owned by PlatformConfiguration
— never recreate it per call.
fun MainViewController() = ComposeUIViewController {
val configuration = remember {
PlatformConfiguration.create().also {
it.initializeCallConfiguration()
}
}
App(configuration = configuration)
}
Info.plist
| Key | Purpose |
|---|---|
NSMicrophoneUsageDescription | Microphone access during calls |
NSBluetoothAlwaysUsageDescription | Bluetooth headset support |
Android Permissions
Declare these in your AndroidManifest.xml:
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
| 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 phone number |
MODIFY_AUDIO_SETTINGS | Switch audio routes |
RECORD_AUDIO | Microphone access during calls |
BLUETOOTH / BLUETOOTH_CONNECT | Bluetooth headset support |
iOS Permissions
CallKit handles call access at the system level — no runtime CALL_PHONE equivalent is required.
| Info.plist Key | Purpose |
|---|---|
NSMicrophoneUsageDescription | Microphone access during calls |
NSBluetoothAlwaysUsageDescription | Bluetooth headset support |
Runtime Permission Handling
Bind the controller in your Composable
@Composable
fun App(configuration: PlatformConfiguration) {
val factory = rememberPermissionsControllerFactory()
val controller = remember(factory) { factory.createPermissionsController() }
BindEffect(controller)
}
Request a permission
scope.launch {
when (controller.getPermissionState(Permission.CALL_PHONE)) {
PermissionState.Granted -> placeCall()
PermissionState.DeniedAlways -> controller.openAppSettings()
else -> {
try {
controller.providePermission(Permission.CALL_PHONE)
placeCall()
} catch (e: DeniedAlwaysException) {
controller.openAppSettings()
} catch (e: DeniedException) {
showPermissionRationale()
}
}
}
}
Available permissions
Placing Calls
Setup
val configuration = PlatformConfiguration.create()
configuration.initializeCallConfiguration()
val callManager = CallManager(configuration)
Outgoing call
val result = callManager.startOutgoingCall(
number = "+1234567890",
displayName = "John Doe"
)
when (result) {
is CallResult.Success -> println("Call started: ${result.data.id}")
is CallResult.Error -> println("Failed: ${result.error}")
}
SIP / VoIP call
callManager.startOutgoingCall(
number = "alice@sip.example.com",
displayName = "Alice",
scheme = "sip"
)
End / Answer / Decline
callManager.endCall(call.id)
callManager.answerCall(call.id)
callManager.declineCall(call.id)
Call Controls
callManager.muteCall(call.id, muted = true)
callManager.muteCall(call.id, muted = false)
callManager.holdCall(call.id, onHold = true)
callManager.holdCall(call.id, onHold = false)
val stateResult = callManager.getCallState(call.id)
val allCalls = callManager.getActiveCalls()
CallState values
Audio Routing
callManager.setAudioRoute(AudioRoute.SPEAKER)
callManager.setAudioRoute(AudioRoute.EARPIECE)
callManager.setAudioRoute(AudioRoute.BLUETOOTH)
callManager.setAudioRoute(AudioRoute.WIRED_HEADSET)
val routeResult = callManager.getCurrentAudioRoute()
| Route | Android | iOS |
|---|---|---|
SPEAKER |
Built-in loudspeaker via AudioManager |
AVAudioSession port override |
EARPIECE |
Handset earpiece via AudioManager |
Removes speaker override |
BLUETOOTH |
Bluetooth SCO device | Auto-routes to connected BT device |
WIRED_HEADSET |
Wired headset (API 31+) | Auto-routes to connected headset |
BLUETOOTH and WIRED_HEADSET, iOS routes automatically when the speaker override is removed. Use AVRoutePickerView for fine-grained Bluetooth device selection.
Callbacks
val callback = object : CallStateCallback {
override fun onCallStateChanged(call: Call) {
when (call.state) {
CallState.ACTIVE -> showActiveCallUI(call)
CallState.ENDED -> dismissCallUI()
CallState.HOLDING -> showOnHoldBadge()
else -> {}
}
}
override fun onCallAdded(call: Call) { showIncomingCallUI(call) }
override fun onCallRemoved(call: Call) { dismissCallUI() }
}
callManager.registerCallStateCallback(callback)
callManager.unregisterCallStateCallback(callback)
DisposableEffect to tie callback lifetime to composable lifetime.
CallResult
Every CallManager operation returns CallResult<T>. No exceptions leak through the API boundary.
sealed class CallResult<out T> {
data class Success<T>(val data: T) : CallResult<T>()
data class Error(val error: CallError) : CallResult<Nothing>()
}
CallError variants
| Error | Meaning |
|---|---|
CallNotFound(callId) | No active call with that ID |
PermissionDenied(reason) | Required permission not granted |
ServiceError(message, code) | Platform service failure |
AudioDeviceError(reason) | Audio routing failure |
SipUnsupported(reason) | SIP not available (Android 12+) |
ServiceNotInitialized(reason) | Called before initializeCallConfiguration() |
CallScreen
Ringlr ships a ready-to-use Compose Multiplatform call screen.
Drop it into App() and you get a complete calling UI with no extra work.
@Composable
fun App(configuration: PlatformConfiguration) {
val callManager = remember(configuration) {
configuration.initializeCallConfiguration()
CallManager(configuration)
}
val factory = rememberPermissionsControllerFactory()
val permissionsController = remember(factory) { factory.createPermissionsController() }
BindEffect(permissionsController)
MaterialTheme(colorScheme = darkColorScheme()) {
CallScreen(
callManager = callManager,
permissionsController = permissionsController
)
}
}
CallScreen routes between three sub-screens based on call state:
UI Components
| Component | Purpose |
|---|---|
Dialer | Phone number input field and permission-guarded Call button |
IncomingCallScreen | Caller info card with Accept / Decline buttons |
ActiveCallPanel | Active-call card: name, state badge, controls, End Call |
CallControlRow | Mute and Hold toggle chips |
AudioRouteSelector | Row of chips for switching audio output |
CallStateBadge | Colored pill showing current CallState |
CallerInfo | Display name + number typography block |
CallResponseRow | Accept / Decline button pair |
ObserveCallState | DisposableEffect that registers and auto-cleans a CallStateCallback |
VoIP / SIP Overview
Ringlr provides the system-UI and call-lifecycle layer for VoIP/SIP — it does not bundle a SIP stack or media engine. You bring your own.
| Layer | Ringlr's role | Your responsibility |
|---|---|---|
| System call UI (CallKit / Telecom) | Handled | — |
| Call state (dialing, active, hold, end) | Handled | — |
| SIP signaling (REGISTER, INVITE, BYE) | Not included | Your SIP stack |
| Media / audio (RTP, codecs, SRTP) | Not included | Your SIP stack |
Android
Android removed its built-in SipManager in Android 12 (API 31).
- API ≤ 30:
configureSipAccount()sets up the system SIP manager automatically. - API 31+:
configureSipAccount()returnsCallError.SipUnsupported. Establish your session with Linphone, PJSIP, or WebRTC, then callstartOutgoingCall(scheme = "sip").
myVoipStack.connect(sipAddress) { sessionEstablished ->
scope.launch {
callManager.startOutgoingCall(
number = sipAddress,
displayName = "Alice",
scheme = "sip"
)
}
}
iOS
CallKit is transport-agnostic. configureSipAccount() stores the profile for your own use; CallKit shows the system UI via startOutgoingCall(scheme = "sip") regardless of the underlying protocol.
callManager.configureSipAccount(
SipProfile(
username = "alice",
server = "sip.example.com",
password = "secret",
port = 5060,
displayName = "Alice"
)
)
callManager.startOutgoingCall(
number = "bob@sip.example.com",
displayName = "Bob",
scheme = "sip"
)
VoIP Push Notifications
VoIP push wakes the app for an incoming call even when it is suspended. Without it, iOS will not reliably display the incoming call screen.
iOS — PushKit (automatic)
val registrar = VoipPushRegistrar(platformConfig)
registrar.register(object : VoipPushListener {
override fun onTokenRefreshed(token: String) { sendTokenToServer(token) }
override fun onIncomingCall(payload: VoipPushPayload) {
// CallKit system UI is shown by Ringlr automatically.
}
})
| Payload key | Value |
|---|---|
call_id | Unique call identifier |
caller_number | Phone number or SIP address |
caller_name | Display name |
scheme | "sip" or "tel" |
Android — FCM bridge
// 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 */ }
})
// 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"
)
)
}
}
Project Structure
shared/
└── src/
├── commonMain/
│ ├── CallScreen.kt
│ ├── App.kt
│ └── call/
│ ├── CallHandler.kt # expect PlatformConfiguration + CallManager
│ ├── CallManagerInterface.kt
│ ├── Call.kt · CallState.kt · CallResult.kt · CallError.kt
│ ├── AudioRoute.kt · CallStateCallback.kt
│ └── voip/
│ ├── VoipPushRegistrar.kt
│ ├── VoipPushListener.kt
│ └── VoipPushPayload.kt
├── androidMain/
│ └── call/
│ ├── CallHandler.android.kt
│ ├── TelecomConnectionService.kt
│ ├── PhoneAccountRegistrar.kt
│ └── voip/VoipPushRegistrar.android.kt
└── iosMain/
└── call/
├── CallHandler.ios.kt
├── callkit/
│ ├── ActiveCallRegistry.kt
│ ├── SystemCallBridge.kt
│ ├── CallActionDispatcher.kt
│ └── CallAudioRouter.kt
└── voip/VoipPushRegistrar.ios.kt
demoApp/
└── src/main/kotlin/io/jadu/ringlr/demo/
├── DemoApplication.kt
└── MainActivity.kt
Important Notes
- Always call
initializeCallConfiguration()beforeCallManager— the platform bridges are set up lazily; skipping this causes silent failures. - Call
cleanupCallConfiguration()when done — on iOS this invalidates theCXProvider; on Android it unregisters the phone account. - Android: register
TelecomConnectionServicein the manifest — calls will silently fail without it. - iOS:
PlatformConfigurationis a singleton — never recreate it per call; theCXProvidermust be stable for the lifetime of the app. - Always unregister callbacks — use
DisposableEffectin Compose to tie callback lifetime to composable lifetime. - SIP on Android 12+ requires an external stack — the system
SipManagerwas removed in API 31.
Troubleshooting
| Problem | Solution |
|---|---|
| Call never starts on Android | Check TelecomConnectionService is declared in the manifest with BIND_TELECOM_CONNECTION_SERVICE permission |
| Permission permanently denied | Call controller.openAppSettings() to redirect the user to system settings |
| 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) |
Use the id from the Call returned by startOutgoingCall(), not a self-generated ID |
SipUnsupported on Android |
Android 12+ dropped system SIP. Use an external stack (Linphone, PJSIP, WebRTC), then call startOutgoingCall(scheme = "sip") |