Started experimenting with baresip as PSTN dialer

This commit is contained in:
Juha Heinanen
2026-05-08 17:25:19 +03:00
parent 20d1312d0f
commit 0a04a3e132
8 changed files with 309 additions and 66 deletions

View File

@ -1748,6 +1748,77 @@ class BaresipService: Service() {
}
}
fun handleExternalCall(telecomCall: android.telecom.Call) {
val uri = telecomCall.details.handle?.schemeSpecificPart ?: "Unknown"
Log.d(TAG, "Handling external call from $uri")
if (uas.value.isEmpty()) {
Log.e(TAG, "No User Agents available to handle external call")
return
}
val ua = UserAgent.statusMap().keys.firstOrNull()?.let { UserAgent.ofAor(it) } ?: uas.value[0]
val telecomState = if (VERSION.SDK_INT >= 31)
telecomCall.details.state
else
@Suppress("DEPRECATION") telecomCall.state
val initialStatus = when (telecomState) {
android.telecom.Call.STATE_RINGING -> "incoming"
android.telecom.Call.STATE_DIALING, android.telecom.Call.STATE_CONNECTING -> "outgoing"
else -> "connected"
}
val call = Call.ExternalCall(
telecomCall,
ua,
uri,
if (telecomState == android.telecom.Call.STATE_RINGING) "in" else "out",
initialStatus
)
telecomCall.registerCallback(object : android.telecom.Call.Callback() {
override fun onStateChanged(call: android.telecom.Call, state: Int) {
super.onStateChanged(call, state)
val newStatus = when (state) {
android.telecom.Call.STATE_RINGING -> "incoming"
android.telecom.Call.STATE_DIALING, android.telecom.Call.STATE_CONNECTING -> "outgoing"
android.telecom.Call.STATE_ACTIVE -> "connected"
android.telecom.Call.STATE_DISCONNECTED -> "closed"
android.telecom.Call.STATE_HOLDING -> {
calls.find { it.callp == call.hashCode().toLong() }?.onhold = true
"connected"
}
else -> "connected"
}
calls.find { it.callp == call.hashCode().toLong() }?.let {
if (it.status.value != newStatus) {
it.status.value = newStatus
postServiceEvent(ServiceEvent(
"call update",
arrayListOf(it.ua.uap, it.callp),
System.nanoTime())
)
if (newStatus == "closed")
handleExternalCallRemoved(call)
}
}
}
})
calls.add(call)
postServiceEvent(ServiceEvent(
"call incoming",
arrayListOf(ua.uap, call.callp),
System.nanoTime())
)
}
fun handleExternalCallRemoved(telecomCall: android.telecom.Call) {
val callp = telecomCall.hashCode().toLong()
calls.removeAll { it.callp == callp }
messageUpdate.postValue(System.currentTimeMillis())
}
private fun toast(message: String, length: Int = Toast.LENGTH_SHORT) {
Handler(Looper.getMainLooper()).post {
Toast.makeText(this@BaresipService.applicationContext, message, length).show()

View File

@ -8,7 +8,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.core.net.toUri
import java.util.*
class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: String, initialStatus: String) {
open class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: String, initialStatus: String) {
var status: MutableState<String> = mutableStateOf(initialStatus)
@ -55,11 +55,11 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str
BaresipService.calls.remove(this)
}
fun connect(uri: String): Boolean {
open fun connect(uri: String): Boolean {
return Api.call_connect(callp, uri) == 0
}
fun hold(): Boolean {
open fun hold(): Boolean {
if (onhold) return true
if (Api.call_hold(callp, true)) {
onhold = true
@ -71,7 +71,7 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str
return false
}
fun resume(): Boolean {
open fun resume(): Boolean {
if (!onhold && !held) return true
// 1. Hold other calls first
for (c in BaresipService.calls) {
@ -94,13 +94,13 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str
return false
}
fun transfer(uri: String): Boolean {
open fun transfer(uri: String): Boolean {
if (!onhold) hold()
Log.d(TAG, "Transferring call $callp to $uri")
return Api.call_transfer(callp, uri) == 0
}
fun executeTransfer(): Boolean {
open fun executeTransfer(): Boolean {
return if (onHoldCall != null) {
if (Api.call_hold(callp, true))
Api.call_replace_transfer(onHoldCall!!.callp, callp)
@ -110,31 +110,31 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str
false
}
fun sendDigit(digit: Char): Int {
open fun sendDigit(digit: Char): Int {
return Api.call_send_digit(callp, digit)
}
fun notifySipfrag(code: Int, reason: String) {
open fun notifySipfrag(code: Int, reason: String) {
Api.call_notify_sipfrag(callp, code, reason)
}
fun duration(): Int {
open fun duration(): Int {
return Api.call_duration(callp)
}
fun stats(stream: String): String {
open fun stats(stream: String): String {
return Api.call_stats(callp, stream)
}
fun state(): Int {
open fun state(): Int {
return Api.call_state(callp)
}
fun audioCodecs(): String {
open fun audioCodecs(): String {
return Api.call_audio_codecs(callp)
}
fun replaces(): Boolean {
open fun replaces(): Boolean {
return Api.call_replaces(callp)
}
@ -146,10 +146,53 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str
if (ua.account.mediaEnc != "") security = R.color.colorTrafficRed
}
fun destroy() {
open fun destroy() {
Api.call_destroy(callp)
}
class ExternalCall(
val telecomCall: android.telecom.Call,
ua: UserAgent,
peerUri: String,
dir: String,
initialStatus: String
) : Call(telecomCall.hashCode().toLong(), ua, peerUri, dir, initialStatus) {
override fun connect(uri: String): Boolean {
telecomCall.answer(android.telecom.VideoProfile.STATE_AUDIO_ONLY)
return true
}
override fun hold(): Boolean {
telecomCall.hold()
onhold = true
callOnHold.value = true
return true
}
override fun resume(): Boolean {
telecomCall.unhold()
onhold = false
callOnHold.value = false
return true
}
override fun destroy() {
telecomCall.disconnect()
}
override fun sendDigit(digit: Char): Int {
telecomCall.playDtmfTone(digit)
telecomCall.stopDtmfTone()
return 0
}
override fun duration(): Int = 0
override fun stats(stream: String): String = ""
override fun state(): Int = 0
override fun audioCodecs(): String = "PSTN"
}
companion object {
fun calls(): ArrayList<Call> {

View File

@ -1,6 +1,7 @@
package com.tutpro.baresip
import android.content.Intent
import android.net.Uri
import android.telecom.CallAudioState
import android.telecom.Connection
import android.telecom.ConnectionRequest
@ -8,7 +9,6 @@ import android.telecom.ConnectionService
import android.telecom.DisconnectCause
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import android.net.Uri
import java.util.concurrent.ConcurrentHashMap
class ConnectionService : ConnectionService() {
@ -64,7 +64,8 @@ class ConnectionService : ConnectionService() {
val connection = BaresipConnection(uap, callp)
connections[callp] = connection
connection.setAddress(Uri.fromParts("sip", peerUri, null), TelecomManager.PRESENTATION_ALLOWED)
connection.setAddress(Uri.fromParts("sip", peerUri, null),
TelecomManager.PRESENTATION_ALLOWED)
connection.connectionCapabilities = Connection.CAPABILITY_SUPPORT_HOLD or
Connection.CAPABILITY_HOLD or
Connection.CAPABILITY_MERGE_CONFERENCE or
@ -108,6 +109,9 @@ class ConnectionService : ConnectionService() {
val conferenceCall = rootExtras?.getBoolean("conferenceCall", false) ?:
nestedExtras?.getBoolean("conferenceCall") ?: false
val pstnCall = rootExtras?.getBoolean("pstnCall", false) ?:
nestedExtras?.getBoolean("pstnCall") ?: false
val onHoldCallp = rootExtras?.getLong("onHoldCallp", 0L).takeIf { it != 0L }
?: nestedExtras?.getLong("onHoldCallp") ?: 0L
@ -123,20 +127,15 @@ class ConnectionService : ConnectionService() {
Connection.CAPABILITY_HOLD or
Connection.CAPABILITY_MERGE_CONFERENCE or
Connection.CAPABILITY_SWAP_CONFERENCE
connection.audioModeIsVoip = true
// Start the SIP connection logic
if (uap != 0L) {
if (!pstnCall) {
connection.audioModeIsVoip = true
val sipUri = if (destination.startsWith("sip:")) destination else "sip:$destination"
BaresipService.instance?.runCall(uap, sipUri, conferenceCall, onHoldCallp)
} else {
Log.e(TAG, "Cannot start outgoing call: uap is 0")
connection.setDisconnected(DisconnectCause(DisconnectCause.ERROR, "No Account"))
connection.destroy()
pendingOutgoingConnection = null
}
connection.setDialing()
return connection
}

View File

@ -1,24 +1,35 @@
package com.tutpro.baresip
import android.app.Service
import android.content.Intent
import android.os.IBinder
// This is needed in order to allow choosing baresip as default Phone app
class InCallService : Service() {
override fun onBind(intent: Intent): IBinder? {
Log.d(TAG, "InCallService onBind with intent: ${intent.action}")
return null
}
}
/*import android.telecom.InCallService
import android.telecom.Call
import android.telecom.InCallService
class InCallService : InCallService() {
override fun onCallAdded(call: Call) {
super.onCallAdded(call)
// This is triggered when the system wants YOU to show the call UI
Log.d("Baresip", "InCallService: Call added")
Log.d(TAG, "InCallService: Call added")
val handle = call.details.accountHandle
val baresipHandle = BaresipService.getPhoneAccountHandle(this)
if (handle == baresipHandle) {
Log.d(TAG, "InCallService: Identified as SIP call")
// The SIP call is already managed by ConnectionService/BaresipService.
// We just need to ensure the InCallService stays bound.
} else {
Log.d(TAG, "InCallService: Identified as PSTN call from $handle")
// This is a cellular call. We need to wrap it so MainScreen can show it.
BaresipService.instance?.handleExternalCall(call)
}
}
}*/
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
Log.d(TAG, "InCallService: Call removed")
BaresipService.instance?.handleExternalCallRemoved(call)
}
companion object {
private const val TAG = "Baresip"
}
}

View File

@ -12,12 +12,14 @@ import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.os.Build.VERSION
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.telecom.TelecomManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -1473,12 +1475,14 @@ private fun CallRow(
horizontalArrangement = Arrangement.Absolute.SpaceBetween
) {
if (isDialer) {
dialerState.showCallPstnButton.value = Utils.pstnAccountHandle(ctx) != null
if (dialerState.showCallButton.value)
IconButton(
modifier = Modifier.size(48.dp),
enabled = dialerState.callButtonsEnabled.value,
onClick = {
if (!dialerState.callButtonsEnabled.value) return@IconButton
dialerState.showCallPstnButton.value = false
dialerState.showCallConferenceButton.value = false
dialerState.showSuggestions.value = false
callClick(ctx, viewModel, dialerState)
@ -1494,6 +1498,28 @@ private fun CallRow(
contentDescription = null,
)
}
if (dialerState.showCallPstnButton.value)
IconButton(
modifier = Modifier.size(48.dp),
enabled = dialerState.callButtonsEnabled.value,
onClick = {
if (!dialerState.callButtonsEnabled.value) return@IconButton
dialerState.showCallButton.value = false
dialerState.showCallConferenceButton.value = false
dialerState.showSuggestions.value = false
callClick(ctx, viewModel, dialerState)
},
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.call_tel),
modifier = Modifier.size(42.dp),
tint = colorResource(if (dialerState.callButtonsEnabled.value)
R.color.colorTrafficGreen
else
R.color.colorTrafficYellow),
contentDescription = null,
)
}
if (dialerState.showCallConferenceButton.value) {
Spacer(modifier = Modifier.weight(1f, true))
IconButton(
@ -1502,6 +1528,7 @@ private fun CallRow(
onClick = {
if (!dialerState.callButtonsEnabled.value) return@IconButton
dialerState.showCallButton.value = false
dialerState.showCallPstnButton.value = false
dialerState.showSuggestions.value = false
callClick(ctx, viewModel, dialerState)
}
@ -2061,14 +2088,14 @@ private fun callClick(ctx: Context, viewModel: ViewModel, dialerState: ViewModel
ctx,
viewModel,
uriText,
dialerState.showCallConferenceButton.value
dialerState
)
else if (uris.size == 1)
makeCall(
ctx,
viewModel,
uris[0],
dialerState.showCallConferenceButton.value
dialerState
)
else {
selectItems.value = uris
@ -2077,7 +2104,7 @@ private fun callClick(ctx: Context, viewModel: ViewModel, dialerState: ViewModel
ctx,
viewModel,
uris[index],
dialerState.showCallConferenceButton.value
dialerState
)
}
showSelectItemDialog.value = true
@ -2095,8 +2122,8 @@ private fun callClick(ctx: Context, viewModel: ViewModel, dialerState: ViewModel
}
}
private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, conferenceCall: Boolean,
onHoldCallp: Long = 0L) {
private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String,
dialerState: ViewModel.DialerState, onHoldCallp: Long = 0L) {
val aor = viewModel.selectedAor.value
val ua = UserAgent.ofAor(aor)!!
val peerUri = if (Utils.isTelNumber(uriText))
@ -2104,13 +2131,16 @@ private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, confer
else
uriText
val uri = if (Utils.isTelUri(peerUri)) {
if (ua.account.telProvider == "") {
if (dialerState.showCallPstnButton.value)
peerUri
else if (ua.account.telProvider == "") {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.no_telephony_provider), aor)
showAlert.value = true
return
}
Utils.telToSip(peerUri, ua.account)
else
Utils.telToSip(peerUri, ua.account)
}
else
Utils.uriComplete(peerUri, aor)
@ -2118,28 +2148,62 @@ private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, confer
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri)
showAlert.value = true
return
}
else if (dialerState.showCallPstnButton.value && !Utils.isTelUri(uri)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "Telephone call can only be made to telephone number"
showAlert.value = true
return
}
else if (Utils.isAudioMode(ctx,AudioManager.MODE_IN_CALL) &&
!Call.calls().any { it.ua.account.aor == ua.account.aor })
Toast.makeText(ctx, R.string.call_already_active, Toast.LENGTH_SHORT).show()
else {
viewModel.dialerState.callButtonsEnabled.value = false
var error = ""
if (BaresipService.telecom) {
val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as android.telecom.TelecomManager
val extras = android.os.Bundle()
extras.putParcelable(android.telecom.TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE,
BaresipService.getPhoneAccountHandle(ctx))
val callExtras = android.os.Bundle()
callExtras.putBoolean("conferenceCall", conferenceCall)
callExtras.putLong("uap", ua.uap)
if (onHoldCallp != 0L)
callExtras.putLong("onHoldCallp", onHoldCallp)
extras.putBundle(android.telecom.TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, callExtras)
try {
Log.d(TAG, "Placing Telecom call to $uri with uap=${ua.uap}")
tm.placeCall(uri.toUri(), extras)
} catch (e: SecurityException) {
Log.e(TAG, "placeCall failed: ${e.message}")
val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
if (dialerState.showCallPstnButton.value) {
val phoneAccountHandle = Utils.pstnAccountHandle(ctx)
if (phoneAccountHandle != null) {
val extras = Bundle().apply {
putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccountHandle)
}
val callExtras = Bundle()
callExtras.putBoolean("pstnCall", dialerState.showCallPstnButton.value)
extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, callExtras)
try {
Log.i(TAG, "Placing Telecom PSTN call to $uri with uap=${ua.uap}")
tm.placeCall(uri.toUri(), extras)
} catch (e: SecurityException) {
error = "placeCall failed: ${e.message}"
}
}
else
error = "no phone account"
}
else {
val extras = Bundle()
extras.putParcelable(
TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE,
BaresipService.getPhoneAccountHandle(ctx)
)
val callExtras = Bundle()
callExtras.putBoolean("conferenceCall", dialerState.showCallConferenceButton.value)
callExtras.putLong("uap", ua.uap)
if (onHoldCallp != 0L)
callExtras.putLong("onHoldCallp", onHoldCallp)
extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, callExtras)
try {
Log.d(TAG, "Placing Telecom SIP call to $uri with uap=${ua.uap}")
tm.placeCall(uri.toUri(), extras)
} catch (e: SecurityException) {
error = "placeCall failed: ${e.message}"
}
}
if (error != "") {
Log.e(TAG, error)
viewModel.dialerState.callButtonsEnabled.value = true
}
}
@ -2148,7 +2212,7 @@ private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, confer
intent.action = "Start Call"
intent.putExtra("uap", ua.uap)
intent.putExtra("uri", uri)
intent.putExtra("conferenceCall", conferenceCall)
intent.putExtra("conferenceCall", dialerState.showCallConferenceButton.value)
intent.putExtra("onHoldCallp", onHoldCallp)
ctx.startService(intent)
}
@ -2207,7 +2271,7 @@ private fun transfer(ctx: Context, viewModel: ViewModel, ua: UserAgent, uriText:
if (success) {
call.onhold = true
call.referTo = uri
makeCall(ctx, viewModel, uri, false, call.callp)
makeCall(ctx, viewModel, uri, viewModel.dialerState, call.callp)
showCall(ctx, viewModel, ua, call)
}
}
@ -2237,6 +2301,7 @@ private fun showCall(ctx: Context, viewModel: ViewModel, ua: UserAgent?, showCal
viewModel.dialerState.callUriEnabled.value = true
}, 100)
viewModel.dialerState.showCallButton.value = true
viewModel.dialerState.showCallPstnButton.value = true
viewModel.dialerState.showCallConferenceButton.value = true
viewModel.dialerState.callButtonsEnabled.value = true
viewModel.dialerState.showSuggestions.value = false
@ -2521,7 +2586,7 @@ fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params
if (call in Call.calls())
acceptTransfer(ctx, viewModel, ua, call!!, ev[1])
else
makeCall(ctx, viewModel, ev[1], false)
makeCall(ctx, viewModel, ev[1], viewModel.dialerState)
}
showDialog.value = true
}
@ -2530,7 +2595,7 @@ fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params
val call = Call.ofCallp(callp)
if (call in Call.calls())
Api.ua_hangup(uap, callp, 487, "Request Terminated")
makeCall(ctx, viewModel, ev[1], false)
makeCall(ctx, viewModel, ev[1], viewModel.dialerState)
showCall(ctx, viewModel, ua)
}
"transfer failed" -> {

View File

@ -1,5 +1,6 @@
package com.tutpro.baresip
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.KeyguardManager
@ -1323,6 +1324,26 @@ object Utils {
return file
}
fun pstnAccountHandle(ctx: Context): PhoneAccountHandle? {
if (ctx.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) ==
PackageManager.PERMISSION_GRANTED) {
val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
if (Build.VERSION.SDK_INT >= 29) {
val preferredHandle: PhoneAccountHandle? = tm.userSelectedOutgoingPhoneAccount
if (preferredHandle != null)
return preferredHandle
}
val baresipHandle = BaresipService.getPhoneAccountHandle(ctx)
val phoneAccounts = tm.callCapablePhoneAccounts.filter { it != baresipHandle }
return if (phoneAccounts.isNotEmpty())
phoneAccounts[0]
else
null
}
else
return null
}
@Suppress("unused")
fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath)

View File

@ -44,6 +44,7 @@ class ViewModel: ViewModel() {
val callUriLabel: MutableState<String> = mutableStateOf(""),
val showSuggestions: MutableState<Boolean> = mutableStateOf(false),
val showCallButton: MutableState<Boolean> = mutableStateOf(true),
val showCallPstnButton: MutableState<Boolean> = mutableStateOf(true),
val showCallConferenceButton: MutableState<Boolean> = mutableStateOf(true),
val callButtonsEnabled: MutableState<Boolean> = mutableStateOf(true),
val conferenceCall: MutableState<Boolean> = mutableStateOf(false),

View File

@ -0,0 +1,32 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<!-- Phone handle -->
<path
android:fillColor="@android:color/white"
android:pathData="
M798,840Q673,840 551,785.5Q429,731 329,631Q229,531 174.5,409Q120,287 120,162Q120,144 132,132Q144,120 162,120L324,120Q338,120 349,129.5Q360,139 362,152L388,292Q390,308 387,319Q384,330 376,338L279,436Q299,473 326.5,507.5Q354,542 387,574Q418,605 452,631.5Q486,658 524,680L618,586Q627,577 641.5,572.5Q656,568 670,570L808,598Q822,602 831,612.5Q840,623 840,636L840,798Q840,816 828,828Q816,840 798,840ZM241,360L307,294L290,200L201,200Q206,241 215,281Q224,321 241,360ZM599,718Q638,735 678.5,745Q719,755 760,758L760,670L666,651L599,718Z" />
<!-- Letter T -->
<path
android:fillColor="@android:color/white"
android:pathData="
M480,120L600,120L600,160L560,160L560,320L520,320L520,160L480,160Z" />
<!-- Letter E -->
<path
android:fillColor="@android:color/white"
android:pathData="
M620,120L740,120L740,160L660,160L660,200L730,200L730,240L660,240L660,280L740,280L740,320L620,320Z" />
<!-- Letter L -->
<path
android:fillColor="@android:color/white"
android:pathData="
M760,120L800,120L800,280L860,280L860,320L760,320Z" />
</vector>