Kotlin Multiplatform

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

Outgoing & Incoming Calls

Place PSTN and SIP calls; answer or decline incoming calls via Ringlr or the system UI.

Mute & Hold

Toggle mute and hold on any active call with a single suspend call.

Audio Routing

Switch between speaker, earpiece, Bluetooth, and wired headset at runtime.

Real-time Callbacks

Register CallStateCallback to react to call lifecycle events as they happen.

VoIP / SIP

System-UI integration for SIP and VoIP calls. Bring your own media stack.

VoIP Push

PushKit on iOS; FCM bridge on Android — wake the app for incoming calls.

Permission Handling

Unified permission controller for microphone, call, and Bluetooth across platforms.

Built-in Call UI

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.

commonMain
CallScreen
CallManager (expect)
CallManagerInterface
Call · CallState · CallResult
androidMain
CallManager (actual)
TelecomConnectionService
PhoneAccountRegistrar
iosMain
CallManager (actual)
SystemCallBridge
ActiveCallRegistry
CallAudioRouter

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>
Required: Without the 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

KeyPurpose
NSMicrophoneUsageDescriptionMicrophone access during calls
NSBluetoothAlwaysUsageDescriptionBluetooth headset support
Note: iOS CallKit is transport-agnostic — the system call UI appears regardless of whether you are using PSTN, SIP, or WebRTC. No runtime "CALL_PHONE" equivalent is required.

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" />
PermissionPurpose
ANSWER_PHONE_CALLSAnswer incoming calls programmatically
CALL_PHONEPlace outgoing calls
READ_PHONE_STATERead active call state
MANAGE_OWN_CALLSRegister a custom phone account
READ_PHONE_NUMBERSRead the device phone number
MODIFY_AUDIO_SETTINGSSwitch audio routes
RECORD_AUDIOMicrophone access during calls
BLUETOOTH / BLUETOOTH_CONNECTBluetooth headset support

iOS Permissions

CallKit handles call access at the system level — no runtime CALL_PHONE equivalent is required.

Info.plist KeyPurpose
NSMicrophoneUsageDescriptionMicrophone access during calls
NSBluetoothAlwaysUsageDescriptionBluetooth 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

Permission.CALL_PHONE Permission.MICROPHONE Permission.BLUETOOTH Permission.BLUETOOTH_CONNECT Permission.RECORD_AUDIO

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

DIALING
RINGING
ACTIVE
HOLDING
ENDED

Audio Routing

callManager.setAudioRoute(AudioRoute.SPEAKER)
callManager.setAudioRoute(AudioRoute.EARPIECE)
callManager.setAudioRoute(AudioRoute.BLUETOOTH)
callManager.setAudioRoute(AudioRoute.WIRED_HEADSET)

val routeResult = callManager.getCurrentAudioRoute()
RouteAndroidiOS
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
iOS note: For 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)
Memory: Always unregister callbacks when your component is destroyed. In Compose, use 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

ErrorMeaning
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:

No active call
Ringlr Demo
+1 555 000 0000
Call
Dialer
Incoming call
Incoming Call
John Doe
+1 555 000 0000
Decline
Accept
IncomingCallScreen
Call in progress
John Doe
ACTIVE
Mute Hold
End Call
ActiveCallPanel

UI Components

ComponentPurpose
DialerPhone number input field and permission-guarded Call button
IncomingCallScreenCaller info card with Accept / Decline buttons
ActiveCallPanelActive-call card: name, state badge, controls, End Call
CallControlRowMute and Hold toggle chips
AudioRouteSelectorRow of chips for switching audio output
CallStateBadgeColored pill showing current CallState
CallerInfoDisplay name + number typography block
CallResponseRowAccept / Decline button pair
ObserveCallStateDisposableEffect 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.

LayerRinglr's roleYour responsibility
System call UI (CallKit / Telecom)Handled
Call state (dialing, active, hold, end)Handled
SIP signaling (REGISTER, INVITE, BYE)Not includedYour SIP stack
Media / audio (RTP, codecs, SRTP)Not includedYour SIP stack

Android

Android removed its built-in SipManager in Android 12 (API 31).

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 keyValue
call_idUnique call identifier
caller_numberPhone number or SIP address
caller_nameDisplay 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

Troubleshooting

ProblemSolution
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")