Merge branch 'calling_app'

This commit is contained in:
Juha Heinanen
2026-04-03 15:10:09 +03:00
11 changed files with 767 additions and 532 deletions
@@ -177,9 +177,6 @@ private var password = mutableStateOf("")
private val selectItems = mutableStateOf(listOf<String>())
private val selectItemAction = mutableStateOf<(Int) -> Unit>({ _ -> run {} })
private val showSelectItemDialog = mutableStateOf(false)
private var callRunnable: Runnable? = null
private var callHandler: Handler = Handler(Looper.getMainLooper())
private var audioModeChangedListener: AudioManager.OnModeChangedListener? = null
fun NavGraphBuilder.mainScreenRoute(
navController: NavController,
@@ -529,7 +526,7 @@ private fun TopAppBar(
val recOffImage = Icons.Filled.VoiceOverOff
val recOnImage = Icons.Filled.RecordVoiceOver
var isRecOn by remember { mutableStateOf(BaresipService.isRecOn) }
val isSpeakerOn = remember { mutableStateOf(Utils.isSpeakerPhoneOn(am)) }
val isSpeakerOn by viewModel.isSpeakerOn.collectAsState()
var menuExpanded by remember { mutableStateOf(false) }
val about = stringResource(R.string.about)
@@ -648,11 +645,22 @@ private fun TopAppBar(
.clip(CircleShape)
.combinedClickable(
onClick = {
if (Build.VERSION.SDK_INT >= 31)
Log.d(TAG, "Toggling speakerphone when dev/mode is " +
"${am.communicationDevice!!.type}/${am.mode}")
isSpeakerOn.value = !Utils.isSpeakerPhoneOn(am)
Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(ctx), am)
val isCurrentlyOn = isSpeakerOn
val aor = viewModel.selectedAor.value
val ua = uas.value.find { it.account.aor == aor }
val call = ua?.currentCall()
val connection = if (call != null) ConnectionService.connections[call.callp] else null
if (connection != null) {
@Suppress("DEPRECATION")
connection.setAudioRoute(
if (isCurrentlyOn)
android.telecom.CallAudioState.ROUTE_EARPIECE
else
android.telecom.CallAudioState.ROUTE_SPEAKER
)
} else {
Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(ctx), am)
}
},
onLongClick = {
alertTitle.value = speakerPhoneTitle
@@ -664,7 +672,7 @@ private fun TopAppBar(
Icon(
imageVector = Icons.Filled.SpeakerPhone,
contentDescription = null,
tint = if (isSpeakerOn.value)
tint = if (isSpeakerOn)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onPrimary,
@@ -884,8 +892,7 @@ private fun MainContent(navController: NavController, viewModel: ViewModel, cont
val calls by viewModel.calls.collectAsState()
val selectedAor by viewModel.selectedAor.collectAsState()
val filteredCalls = calls.filter { it.ua.account.aor == selectedAor }
val filteredCalls = calls.filter { it.ua.account.aor == selectedAor && it.status.value != "disconnecting" }
val dialingOrRinging = filteredCalls.any { it.status.value == "outgoing" || it.status.value == "incoming" }
val conferenceCall = filteredCalls.any { it.conferenceCall }
@@ -1483,12 +1490,13 @@ private fun CallRow(
onClick = {
if (call.terminated.value) return@IconButton
call.terminated.value = true
abandonAudioFocus(ctx)
Log.d(
TAG,
"AoR ${call.ua.account.aor} canceling call ${call.callp} with ${call.callUri.value}"
)
Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated")
val connection = ConnectionService.connections[call.callp]
if (connection != null)
connection.onDisconnect()
else {
Log.d(TAG, "AoR ${call.ua.account.aor} canceling call ${call.callp}")
Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated")
}
},
) {
Icon(
@@ -1509,9 +1517,13 @@ private fun CallRow(
onClick = {
if (call.terminated.value) return@IconButton
call.terminated.value = true
abandonAudioFocus(ctx)
Log.d(TAG, "AoR ${call.ua.account.aor} hanging up call ${call.callp} with ${call.callUri.value}")
Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated")
val connection = ConnectionService.connections[call.callp]
if (connection != null)
connection.onDisconnect()
else {
Log.d(TAG, "AoR ${call.ua.account.aor} hanging up call ${call.callp}")
Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated")
}
}
) {
Icon(
@@ -1522,34 +1534,42 @@ private fun CallRow(
)
}
IconButton(
modifier = Modifier.size(48.dp),
onClick = {
if (call.onhold) {
Log.d(
TAG,
"AoR ${call.ua.account.aor} resuming call ${call.callp} with ${call.callUri.value}"
)
call.resume()
} else {
Log.d(
TAG,
"AoR ${call.ua.account.aor} holding call ${call.callp} with ${call.callUri.value}"
)
call.hold()
}
},
) {
Icon(
imageVector = Icons.Outlined.PauseCircle,
modifier = Modifier.size(42.dp),
tint = if (call.callOnHold.value)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.secondary,
contentDescription = null,
)
}
if (!call.conferenceCall)
IconButton(
modifier = Modifier.size(48.dp),
onClick = {
val connection = ConnectionService.connections[call.callp]
if (call.onhold) {
Log.d(
TAG,
"AoR ${call.ua.account.aor} resuming call ${call.callp} with ${call.callUri.value}"
)
if (connection != null)
connection.onUnhold()
else
call.resume()
} else {
Log.d(
TAG,
"AoR ${call.ua.account.aor} holding call ${call.callp} with ${call.callUri.value}"
)
if (connection != null)
connection.onHold()
else
call.hold()
}
},
) {
Icon(
imageVector = Icons.Outlined.PauseCircle,
modifier = Modifier.size(42.dp),
tint = if (call.callOnHold.value)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.secondary,
contentDescription = null,
)
}
var showTransferDialog by remember { mutableStateOf(false) }
@@ -1559,12 +1579,22 @@ private fun CallRow(
enabled = call.transferButtonEnabled.value,
onClick = {
if (call.onHoldCall != null) {
if (!call.executeTransfer()) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.transfer_failed)
if (!Api.call_supported(call.callp, Api.REPLACES)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.replaces_not_supported)
showAlert.value = true
}
} else
else {
val connection = ConnectionService.connections[call.callp]
connection?.onHold()
if (!call.executeTransfer()) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.transfer_failed)
showAlert.value = true
}
}
}
else
showTransferDialog = true
},
) {
@@ -1961,16 +1991,18 @@ private fun CallRow(
@Composable
private fun OnHoldNotice() {
OutlinedButton(
onClick = {},
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.padding(16.dp),
shape = RoundedCornerShape(20)
) {
Text(
text = stringResource(R.string.call_is_on_hold),
fontSize = 18.sp
)
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
OutlinedButton(
onClick = {},
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.padding(16.dp),
shape = RoundedCornerShape(20)
) {
Text(
text = stringResource(R.string.call_is_on_hold),
fontSize = 18.sp
)
}
}
}
@@ -2011,10 +2043,10 @@ private fun callClick(ctx: Context, viewModel: ViewModel, dialerState: ViewModel
}
}
private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, conferenceCall: Boolean) {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val ua = UserAgent.ofAor(viewModel.selectedAor.value)!!
val aor = ua.account.aor
private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, conferenceCall: Boolean,
onHoldCallp: Long = 0L) {
val aor = viewModel.selectedAor.value
val ua = UserAgent.ofAor(aor)!!
val peerUri = if (Utils.isTelNumber(uriText))
"tel:$uriText"
else
@@ -2035,44 +2067,31 @@ private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, confer
alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri)
showAlert.value = true
}
else if (Call.isAnyCallActive(ctx))
else if (Utils.isPSTNCallActive(ctx) || Call.calls().any { it.ua.account.aor != ua.account.aor } )
Toast.makeText(ctx, R.string.call_already_active, Toast.LENGTH_SHORT).show()
else if (!BaresipService.requestAudioFocus(ctx))
Toast.makeText(ctx, R.string.audio_focus_denied, Toast.LENGTH_SHORT).show()
else {
viewModel.dialerState.callButtonsEnabled.value = false
if (Build.VERSION.SDK_INT < 31) {
Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION")
am.mode = AudioManager.MODE_IN_COMMUNICATION
runCall(ctx, viewModel, ua, uri, conferenceCall)
} else {
if (am.mode == AudioManager.MODE_IN_COMMUNICATION) {
runCall(ctx, viewModel, ua, uri, conferenceCall)
} else {
audioModeChangedListener = AudioManager.OnModeChangedListener { mode ->
if (mode == AudioManager.MODE_IN_COMMUNICATION) {
Log.d(TAG, "Audio mode changed to MODE_IN_COMMUNICATION using " +
"device ${am.communicationDevice!!.type}")
if (audioModeChangedListener != null) {
am.removeOnModeChangedListener(audioModeChangedListener!!)
audioModeChangedListener = null
}
runCall(ctx, viewModel, ua, uri, conferenceCall)
} else {
Log.d(TAG, "Audio mode changed to mode ${am.mode} using " +
"device ${am.communicationDevice!!.type}")
}
}
am.addOnModeChangedListener(ctx.mainExecutor, audioModeChangedListener!!)
Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION")
am.mode = AudioManager.MODE_IN_COMMUNICATION
}
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}")
}
}
}
private fun answer(ctx: Context, call: Call) {
Log.d(TAG, "AoR ${call.ua.account.aor} answering call from ${call.callUri.value}")
ConnectionService.connections[call.callp]?.setActive()
val intent = Intent(ctx, BaresipService::class.java)
intent.action = "Call Answer"
intent.putExtra("uap", call.ua.uap)
@@ -2082,59 +2101,12 @@ private fun answer(ctx: Context, call: Call) {
private fun reject(call: Call) {
Log.d(TAG, "AoR ${call.ua.account.aor} rejecting call ${call.callp} from ${call.callUri.value}")
call.rejected = true
Api.ua_hangup(call.ua.uap, call.callp, 486, "Busy Here")
}
private fun runCall(ctx: Context, viewModel: ViewModel, ua: UserAgent, uri: String, conferenceCall: Boolean) {
callRunnable = Runnable {
callRunnable = null
val newCall = call(ctx, viewModel, ua, uri, conferenceCall)
if (newCall == null) {
BaresipService.abandonAudioFocus(ctx)
viewModel.dialerState.callButtonsEnabled.value = true
}
}
callHandler.postDelayed(callRunnable!!, BaresipService.audioDelay)
}
private fun call(
ctx: Context,
viewModel: ViewModel,
ua: UserAgent,
uri: String,
conferenceCall: Boolean,
onHoldCall: Call? = null
): Call? {
spinToAor(viewModel, ua.account.aor)
if (conferenceCall && ua.calls().isEmpty())
Api.module_load("mixminus")
val callp = ua.callAlloc(0L, Api.VIDMODE_OFF)
return if (callp != 0L) {
Log.d(TAG, "Adding outgoing call ${ua.uap}/$callp/$uri")
val call = Call(callp, ua, uri, "out", "outgoing")
call.onHoldCall = onHoldCall
call.conferenceCall = conferenceCall
call.add()
if (onHoldCall != null)
onHoldCall.newCall = call
if (call.connect(uri)) {
showCall(ctx, viewModel, ua)
call
} else {
Log.w(TAG, "call_connect $callp failed")
if (onHoldCall != null)
onHoldCall.newCall = null
call.remove()
call.destroy()
showCall(ctx, viewModel, ua)
null
}
} else {
Log.w(TAG, "callAlloc for ${ua.uap} to $uri failed")
if (conferenceCall && ua.calls().isEmpty())
Api.module_unload("mixminus")
null
val connection = ConnectionService.connections[call.callp]
if (connection != null)
connection.onReject()
else {
call.rejected = true
Api.ua_hangup(call.ua.uap, call.callp, 486, "Busy Here")
}
}
@@ -2152,13 +2124,23 @@ private fun transfer(ctx: Context, viewModel: ViewModel, ua: UserAgent, uriText:
val call = ua.currentCall()
if (call != null) {
if (attended) {
if (call.hold()) {
val connection = ConnectionService.connections[call.callp]
val success = if (connection != null) {
connection.onHold()
true
} else {
call.hold()
}
if (success) {
call.onhold = true
call.referTo = uri
call(ctx, viewModel, ua, uri, false,call)
makeCall(ctx, viewModel, uri, false, call.callp)
showCall(ctx, viewModel, ua, call)
}
}
else {
val connection = ConnectionService.connections[call.callp]
connection?.onHold()
if (!call.transfer(uri)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.transfer_failed)
@@ -2205,8 +2187,9 @@ private fun showCall(ctx: Context, viewModel: ViewModel, ua: UserAgent?, showCal
call.focusDtmf.value = true
viewModel.requestShowKeyboard()
}
Log.d(TAG, "Showing call ${call.callp} from ${call.ua.account.aor} with status ${call.status.value}")
when (call.status.value) {
"outgoing", "transferring", "answered" -> {
"outgoing", "transferring", "answered", "disconnecting" -> {
call.callUriLabel.value = if (call.status.value == "answered")
ctx.getString(R.string.incoming_call_from_dots)
else
@@ -2373,6 +2356,9 @@ fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params
"call update" -> {
showCall(ctx, viewModel, ua)
}
"update calls" -> {
viewModel.updateCalls(Call.calls().toList())
}
"call verify" -> {
val callp = params[1] as Long
val call = Call.ofCallp(callp)
@@ -2449,13 +2435,14 @@ 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")
call(ctx, viewModel, ua, ev[1], false)
makeCall(ctx, viewModel, ev[1], false)
showCall(ctx, viewModel, ua)
}
"transfer failed" -> {
showCall(ctx, viewModel, ua)
}
"call closed" -> {
viewModel.updateCalls(Call.calls().toList())
val activity = ctx as? Activity
if (activity != null) {
val kgm = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
@@ -2490,6 +2477,16 @@ fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params
if (aor == viewModel.selectedAor.value)
viewModel.triggerAccountUpdate()
}
"mic muted" -> {
val muted = ev[1].toBoolean()
if (muted)
viewModel.updateMicIcon(Icons.Filled.MicOff)
else
viewModel.updateMicIcon(Icons.Filled.Mic)
}
"speaker" -> {
viewModel.updateSpeakerPhoneStatus(ev[1].toBoolean())
}
else -> Log.e(TAG, "Unknown event '${ev[0]}'")
}
@@ -2769,19 +2766,3 @@ private fun restore(ctx: Context, password: String, onRestartApp: () -> Unit) {
downloadsOutputUri = null
}
private fun abandonAudioFocus(ctx: Context) {
if (Build.VERSION.SDK_INT < 31) {
if (callRunnable != null) {
callHandler.removeCallbacks(callRunnable!!)
callRunnable = null
BaresipService.abandonAudioFocus(ctx)
}
} else {
if (audioModeChangedListener != null) {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.removeOnModeChangedListener(audioModeChangedListener!!)
audioModeChangedListener = null
BaresipService.abandonAudioFocus(ctx)
}
}
}