diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e6c21cde..8a782608 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
tools:ignore="ForegroundServicesPolicy" />
+
@@ -25,6 +26,8 @@
+
+
@@ -83,8 +86,21 @@
+
+
+
+
+
+
+
()
private var mediaPlayer: MediaPlayer? = null
private var androidContactsObserverRegistered = false
- private var bluetoothReceiverRegistered = false
private var hotSpotReceiverRegistered = false
+ private var isNotificationInCall = false
private var isServiceClean = false
@SuppressLint("WakelockTimeout")
@@ -128,6 +119,7 @@ class BaresipService: Service() {
super.onCreate()
Log.i(TAG, "BaresipService onCreate")
+ instance = this
intent = Intent("com.tutpro.baresip.EVENT")
intent.setPackage("com.tutpro.baresip")
@@ -279,9 +271,7 @@ class BaresipService: Service() {
hotSpotReceiverRegistered = true
tm = getSystemService(TELECOM_SERVICE) as TelecomManager
-
- btm = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
- btAdapter = btm.adapter
+ registerPhoneAccount()
proximityWakeLock = pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
"com.tutpro.baresip:proximity_wakelock")
@@ -291,74 +281,8 @@ class BaresipService: Service() {
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Baresip")
else
wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "Baresip")
-
wifiLock.setReferenceCounted(false)
- bluetoothReceiver = object : BroadcastReceiver() {
- override fun onReceive(ctx: Context, intent: Intent) {
- when (intent.action) {
- BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED -> {
- val state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE,
- BluetoothHeadset.STATE_DISCONNECTED)
- when (state) {
- BluetoothHeadset.STATE_CONNECTED -> {
- Log.d(TAG, "Bluetooth headset is connected")
- if (audioFocusRequest != null)
- startBluetoothSco(applicationContext, 1000L, 3)
- }
- BluetoothHeadset.STATE_DISCONNECTED -> {
- Log.d(TAG, "Bluetooth headset is disconnected")
- if (audioFocusRequest != null)
- stopBluetoothSco(applicationContext)
- }
-
- }
- }
- BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED -> {
- val state = intent.getIntExtra(BluetoothHeadset.EXTRA_STATE,
- BluetoothHeadset.STATE_AUDIO_DISCONNECTED)
- when (state) {
- BluetoothHeadset.STATE_AUDIO_CONNECTED -> {
- Log.d(TAG, "Bluetooth headset audio is connected")
- }
- BluetoothHeadset.STATE_AUDIO_DISCONNECTED -> {
- Log.d(TAG, "Bluetooth headset audio is disconnected")
- }
- }
- }
- AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
- val state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE,
- AudioManager.SCO_AUDIO_STATE_DISCONNECTED)
- when (state) {
- AudioManager.SCO_AUDIO_STATE_CONNECTING -> {
- Log.d(TAG, "Bluetooth headset SCO is connecting")
- }
- AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
- Log.d(TAG, "Bluetooth headset SCO is connected")
- }
- AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> {
- Log.d(TAG, "Bluetooth headset SCO is disconnected")
- resetCallVolume()
- }
- AudioManager.SCO_AUDIO_STATE_ERROR -> {
- Log.d(TAG, "Bluetooth headset SCO state ERROR")
- }
- }
- }
- }
- }
- }
-
- if (btAdapter != null) {
- val filter = IntentFilter()
- filter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)
- filter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)
- filter.addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
- ContextCompat.registerReceiver(applicationContext, bluetoothReceiver, filter,
- ContextCompat.RECEIVER_NOT_EXPORTED)
- bluetoothReceiverRegistered = true
- }
-
androidContactsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(self: Boolean) {
Log.d(TAG, "Android contacts change")
@@ -566,6 +490,18 @@ class BaresipService: Service() {
}
}
+ "Call Hangup" -> {
+ val callp = intent!!.getLongExtra("callp", 0L)
+ Log.d(TAG, "onStartCommand Hangup action for $callp")
+ val connection = ConnectionService.connections[callp]
+ if (connection != null) {
+ connection.onDisconnect() // This calls Api.ua_hangup(..., 0, "")
+ } else {
+ val call = Call.ofCallp(callp)
+ if (call != null) Api.ua_hangup(call.ua.uap, callp, 0, "")
+ }
+ }
+
"Transfer Deny" -> {
val callp = intent!!.getLongExtra("callp", 0L)
val call = Call.ofCallp(callp)
@@ -686,6 +622,7 @@ class BaresipService: Service() {
super.onDestroy()
Log.d(TAG, "onDestroy at Baresip Service")
cleanService()
+ instance = null
if (isServiceRunning)
sendBroadcast(Intent("com.tutpro.baresip.Restart"))
}
@@ -845,19 +782,20 @@ class BaresipService: Service() {
break
stopMediaPlayer()
setCallVolume()
- if (speakerPhone && !Utils.isSpeakerPhoneOn(am))
- Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(this), am)
proximitySensing(proximitySensing)
}
"call ringing" -> {
+ ConnectionService.connections[callp]?.setRinging()
playRingBack()
return
}
"call progress" -> {
if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0)
stopMediaPlayer()
- else
+ else {
+ ConnectionService.connections[callp]?.setRinging()
playRingBack()
+ }
return
}
"incoming call" -> {
@@ -871,8 +809,6 @@ class BaresipService: Service() {
Utils.friendlyUri(this, peerUri, ua.account))
else if (!Utils.checkPermissions(this, arrayOf(RECORD_AUDIO)))
getString(R.string.no_calls)
- else if (!requestAudioFocus(applicationContext))
- getString(R.string.audio_focus_denied)
else
""
if (toastMsg != "") {
@@ -915,85 +851,20 @@ class BaresipService: Service() {
"call incoming" -> {
val peerUri = ev[1]
Log.d(TAG, "Incoming call $uap/$callp/$peerUri")
- Call(callp, ua, peerUri, "in", "incoming").add()
- if (speakerPhone && !Utils.isSpeakerPhoneOn(am))
- Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(this), am)
- if (ua.account.answerMode == Api.ANSWERMODE_AUTO) {
- val newIntent = Intent(this, MainActivity::class.java)
- newIntent.flags =
- Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
- Intent.FLAG_ACTIVITY_NEW_TASK
- newIntent.putExtra("action", "call answer")
- newIntent.putExtra("callp", callp)
- startActivity(newIntent)
- return
- }
- val channelId = HIGH_CHANNEL_ID
- val callerNumber = peerUri.split(":")[1].split("@")[0]
- if (shouldStartRinging(callerNumber))
- startRinging()
- if (!Utils.isVisible()) {
- val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
- val intent = Intent(applicationContext, MainActivity::class.java)
- .putExtra("action", "call show")
- .putExtra("callp", callp)
- intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
- Intent.FLAG_ACTIVITY_NEW_TASK
- val pi = PendingIntent.getActivity(applicationContext, CALL_REQ_CODE, intent, piFlags)
- val nb = NotificationCompat.Builder(this, channelId)
- val caller = Utils.friendlyUri(this, peerUri, ua.account)
- val callerContact = Contact.findContact(peerUri)
- val personBuilder = Person.Builder().setName(caller)
- val contactColor = callerContact?.color() ?: "#B0B0B0"
- val initial = if (caller.isNotEmpty()) caller.take(1) else "?"
- val textAvatarBitmap = Utils.createTextAvatar(initial,contactColor)
- var icon = IconCompat.createWithBitmap(textAvatarBitmap)
- if (callerContact is Contact.BaresipContact) {
- if (callerContact.avatarImage != null)
- icon = IconCompat.createWithBitmap(callerContact.avatarImage!!.toCircle())
- }
- else if (callerContact is Contact.AndroidContact) {
- if (callerContact.thumbnailUri != null) {
- try {
- val source = ImageDecoder.createSource(contentResolver,
- callerContact.thumbnailUri!!)
- val bitmap = ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
- decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
- // decoder.setTargetSize(256, 256)
- }
- icon = IconCompat.createWithBitmap(bitmap.toCircle())
- } catch (e: Exception) {
- Log.e(TAG, "Failed to load Android contact avatar: $e")
- }
- }
- }
- val person = personBuilder.setIcon(icon).build()
- nb.setSmallIcon(R.drawable.ic_notification_call)
- .setColor(ContextCompat.getColor(this, R.color.colorPrimary))
- .setContentIntent(pi)
- .setCategory(Notification.CATEGORY_CALL)
- .setAutoCancel(false)
- .setOngoing(true)
- .setContentText(getString(R.string.is_calling))
- .setWhen(System.currentTimeMillis())
- .setShowWhen(true)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .setPriority(NotificationCompat.PRIORITY_MAX)
- if (VERSION.SDK_INT < 34 || nm.canUseFullScreenIntent())
- nb.setFullScreenIntent(pi, true)
- val answerIntent = Intent(applicationContext, MainActivity::class.java)
- .putExtra("action", "call answer")
- .putExtra("callp", callp)
- val api = PendingIntent.getActivity(applicationContext, ANSWER_REQ_CODE,
- answerIntent, piFlags)
- val rejectIntent = Intent(this, BaresipService::class.java)
- rejectIntent.action = "Call Reject"
- rejectIntent.putExtra("callp", callp)
- val rpi = PendingIntent.getService(this, REJECT_REQ_CODE, rejectIntent, piFlags)
- nb.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, rpi, api))
- nm.notify(CALL_NOTIFICATION_ID, nb.build())
- return
+
+ if (Call.ofCallp(callp) == null)
+ Call(callp, ua, peerUri, "in", "incoming").add()
+
+ val extras = android.os.Bundle()
+ extras.putLong("uap", uap)
+ extras.putLong("callp", callp)
+ extras.putString("peerUri", peerUri)
+ try {
+ tm.addNewIncomingCall(getPhoneAccountHandle(this), extras)
+ } catch (e: Exception) {
+ Log.e(TAG, "Telecom addNewIncomingCall failed: ${e.message}")
}
+ return
}
"call answered" -> {
stopMediaPlayer()
@@ -1006,27 +877,31 @@ class BaresipService: Service() {
stopMediaPlayer()
}
"call established" -> {
+ ConnectionService.connections[callp]?.setActive()
nm.cancel(CALL_NOTIFICATION_ID)
- Log.d(TAG, "AoR $aor call $callp established in mode ${am.mode}")
- if (am.mode != MODE_IN_COMMUNICATION)
- am.mode = MODE_IN_COMMUNICATION
+ Log.d(TAG, "AoR $aor call $callp established")
call!!.status.value = "connected"
call.onhold = false
- if (ua.account.callHistory)
- call.startTime = GregorianCalendar()
+ call.startTime = GregorianCalendar()
+ updateStatusNotification()
if (!isMainVisible)
return
}
"call update" -> {
- call!!.held = when (ev[1].toInt()) {
+ val held = when (ev[1].toInt()) {
Api.SDP_INACTIVE, Api.SDP_RECVONLY -> true
- else /* Api.SDP_SENDONLY, Api.SDP_SENDRECV */ -> false
+ else -> false
}
+ call!!.held = held
+ val connection = ConnectionService.connections[callp]
+ if (held) connection?.setOnHold() else connection?.setActive()
if (call.state() == Api.CALL_STATE_EARLY) {
if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0)
stopMediaPlayer()
- else
+ else {
+ ConnectionService.connections[callp]?.setRinging()
playRingBack()
+ }
}
if (!isMainVisible || call.status.value != "connected")
return
@@ -1090,6 +965,11 @@ class BaresipService: Service() {
}
"call closed" -> {
Log.d(TAG, "AoR $aor call $callp is closed prm: ${ev[1]}")
+ ConnectionService.connections[callp]?.let {
+ it.setDisconnected(DisconnectCause(DisconnectCause.REMOTE))
+ it.destroy()
+ ConnectionService.connections.remove(callp)
+ }
nm.cancel(CALL_NOTIFICATION_ID)
if (call != null) {
stopRinging()
@@ -1112,16 +992,15 @@ class BaresipService: Service() {
call.onHoldCall = null
}
call.remove()
+ updateStatusNotification()
if (call.conferenceCall && ua.calls().isEmpty())
Api.module_unload("mixminus")
val reason = ev[1]
val tone = ev[2]
- if (tone == "busy") {
+ if (tone == "busy")
playBusy()
- }
else if (!Call.inCall()) {
resetCallVolume()
- abandonAudioFocus(applicationContext)
proximitySensing(false)
}
if (call.dir == "out")
@@ -1410,6 +1289,40 @@ class BaresipService: Service() {
}
}
+ fun runCall(uap: Long, uri: String, conferenceCall: Boolean, onHoldCallp: Long) {
+ val handler = Handler(Looper.getMainLooper())
+ handler.postDelayed({
+ val ua = UserAgent.ofUap(uap)
+ if (ua != null) {
+ if (conferenceCall && ua.calls().isEmpty())
+ Api.module_load("mixminus")
+ val callp = ua.callAlloc(0L, Api.VIDMODE_OFF)
+ if (callp != 0L) {
+ ConnectionService.promoteOutgoingConnection(callp)
+ val onHoldCall = Call.ofCallp(onHoldCallp)
+ 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)) {
+ Log.w(TAG, "call_connect $callp failed")
+ ConnectionService.onCallClosed(callp)
+ call.remove()
+ call.destroy()
+ }
+ }
+ else
+ ConnectionService.pendingOutgoingConnection?.let {
+ it.setDisconnected(DisconnectCause(DisconnectCause.ERROR))
+ it.destroy()
+ ConnectionService.pendingOutgoingConnection = null
+ }
+ }
+ }, audioDelay)
+ }
+
@Suppress("unused")
@Keep
fun started() {
@@ -1466,7 +1379,6 @@ class BaresipService: Service() {
if (VERSION.SDK_INT >= 31)
snb.foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE
snb.setOngoing(true)
- snb.setCategory(Notification.CATEGORY_SERVICE)
val notification = snb.build()
notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or
Notification.FLAG_ONGOING_EVENT
@@ -1476,8 +1388,8 @@ class BaresipService: Service() {
@SuppressLint("UnspecifiedImmutableFlag")
private fun showStatusNotification() {
val intent = Intent(applicationContext, MainActivity::class.java)
- .setAction(Intent.ACTION_MAIN)
- .addCategory(Intent.CATEGORY_LAUNCHER)
+ .setAction(Intent.ACTION_MAIN)
+ .addCategory(Intent.CATEGORY_LAUNCHER)
val pi = PendingIntent.getActivity(applicationContext, STATUS_REQ_CODE, intent,
PendingIntent.FLAG_IMMUTABLE)
val deleteIntent = Intent(this, BaresipService::class.java)
@@ -1490,26 +1402,18 @@ class BaresipService: Service() {
)
val notificationLayout = RemoteViews(packageName, R.layout.status_notification)
snb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .setSmallIcon(R.drawable.ic_notification_b)
- .setContentIntent(pi)
- .setDeleteIntent(dpi)
- .setOngoing(true)
- .setStyle(NotificationCompat.DecoratedCustomViewStyle())
- .setCustomContentView(notificationLayout)
+ .setSmallIcon(R.drawable.ic_notification_b)
+ .setContentIntent(pi)
+ .setDeleteIntent(dpi)
+ .setOngoing(true)
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setStyle(NotificationCompat.DecoratedCustomViewStyle())
+ .setCustomContentView(notificationLayout)
val notification = buildStatusNotification()
try {
- if (VERSION.SDK_INT >= 29)
- startForeground(
- STATUS_NOTIFICATION_ID, notification,
- if (VERSION.SDK_INT >= 30) {
- if (ContextCompat.checkSelfPermission(this, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
- FOREGROUND_SERVICE_TYPE_PHONE_CALL or FOREGROUND_SERVICE_TYPE_MICROPHONE
- else
- FOREGROUND_SERVICE_TYPE_PHONE_CALL
- }
- else
- FOREGROUND_SERVICE_TYPE_PHONE_CALL
- )
+ if (VERSION.SDK_INT >= 34)
+ startForeground(STATUS_NOTIFICATION_ID, notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
else
startForeground(STATUS_NOTIFICATION_ID, notification)
} catch (e: Exception) {
@@ -1517,39 +1421,127 @@ class BaresipService: Service() {
}
}
- private fun updateStatusNotification() {
- val notificationLayout = RemoteViews(packageName, R.layout.status_notification)
- for (i: Int in 0..3) {
- val resId = when (i) {
- 0-> R.id.status0
- 1-> R.id.status1
- 2-> R.id.status2
- else -> R.id.status3
+ fun updateStatusNotification() {
+ val activeCall = Call.calls().find {
+ it.status.value == "connected" || it.status.value == "outgoing" || it.status.value == "answered"
+ }
+
+ val builder = NotificationCompat.Builder(this, LOW_CHANNEL_ID)
+ val intent = Intent(applicationContext, MainActivity::class.java)
+ .setAction(Intent.ACTION_MAIN)
+ .addCategory(Intent.CATEGORY_LAUNCHER)
+
+ val pi = PendingIntent.getActivity(applicationContext, STATUS_REQ_CODE, intent, PendingIntent.FLAG_IMMUTABLE)
+ val deleteIntent = Intent(this, BaresipService::class.java).setAction("Notification Dismissed")
+ val dpi = PendingIntent.getService(this, STATUS_REQ_CODE, deleteIntent, PendingIntent.FLAG_IMMUTABLE)
+
+ builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setSmallIcon(R.drawable.ic_notification_b)
+ .setContentIntent(pi)
+ .setDeleteIntent(dpi)
+ .setOngoing(true)
+
+ if (activeCall != null) {
+ val peerUri = activeCall.peerUri
+ val caller = Utils.friendlyUri(this, peerUri, activeCall.ua.account)
+ val person = Person.Builder().setName(caller).build()
+
+ val hangupIntent = Intent(this, BaresipService::class.java)
+ hangupIntent.action = "Call Hangup"
+ hangupIntent.putExtra("callp", activeCall.callp)
+ val hpi = PendingIntent.getService(this, REJECT_REQ_CODE, hangupIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+
+ builder.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, hpi))
+ .setCategory(Notification.CATEGORY_CALL)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setWhen(activeCall.startTime?.timeInMillis ?: System.currentTimeMillis())
+ .setUsesChronometer(true)
+ .setContentText(if (activeCall.onhold) getString(R.string.call_is_on_hold) else getString(R.string.call_is_connected))
+ } else {
+ // IMPORTANT: Explicitly clear the style first to ensure CallStyle is removed
+ builder.setStyle(null)
+ builder.setStyle(NotificationCompat.DecoratedCustomViewStyle())
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setWhen(0)
+ .setShowWhen(false)
+ .setUsesChronometer(false)
+ .setContentTitle("")
+ .setContentText("")
+
+ val notificationLayout = RemoteViews(packageName, R.layout.status_notification)
+ for (i in 0..3) {
+ val resId = when (i) {
+ 0 -> R.id.status0
+ 1 -> R.id.status1
+ 2 -> R.id.status2
+ else -> R.id.status3
+ }
+ if (i < uas.value.size) {
+ notificationLayout.setImageViewResource(resId, uas.value[i].status)
+ notificationLayout.setViewVisibility(resId, View.VISIBLE)
+ } else {
+ notificationLayout.setViewVisibility(resId, View.INVISIBLE)
+ }
}
- if (i < uas.value.size) {
- notificationLayout.setImageViewResource(resId, uas.value[i].status)
- notificationLayout.setViewVisibility(resId, View.VISIBLE)
+ if (uas.value.size > 4)
+ notificationLayout.setViewVisibility(R.id.etc, View.VISIBLE)
+ else
+ notificationLayout.setViewVisibility(R.id.etc, View.INVISIBLE)
+
+ builder.setCustomContentView(notificationLayout)
+ }
+
+ if (VERSION.SDK_INT >= 31)
+ builder.foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE
+
+ builder.setOngoing(true)
+ val notification = builder.build()
+ notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or Notification.FLAG_ONGOING_EVENT
+
+ try {
+ if (activeCall != null) {
+ val type = if (VERSION.SDK_INT >= 30) {
+ if (ContextCompat.checkSelfPermission(this, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
+ FOREGROUND_SERVICE_TYPE_PHONE_CALL or FOREGROUND_SERVICE_TYPE_MICROPHONE
+ else
+ FOREGROUND_SERVICE_TYPE_PHONE_CALL
+ } else 0
+ if (VERSION.SDK_INT >= 29)
+ startForeground(STATUS_NOTIFICATION_ID, notification, type)
+ else
+ startForeground(STATUS_NOTIFICATION_ID, notification)
+ isNotificationInCall = true
} else {
- notificationLayout.setViewVisibility(resId, View.INVISIBLE)
+ if (isNotificationInCall) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ isNotificationInCall = false
+ if (VERSION.SDK_INT >= 34)
+ startForeground(STATUS_NOTIFICATION_ID, notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
+ else if (VERSION.SDK_INT >= 30)
+ startForeground(STATUS_NOTIFICATION_ID, notification, 0)
+ else
+ startForeground(STATUS_NOTIFICATION_ID, notification)
+ } else {
+ // Use standard notify for standby updates.
+ // This is much more stable for background registration.
+ nm.notify(STATUS_NOTIFICATION_ID, notification)
+ }
}
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to update foreground notification: ${e.message}")
+ nm.notify(STATUS_NOTIFICATION_ID, notification)
}
- if (uas.value.size > 4)
- notificationLayout.setViewVisibility(R.id.etc, View.VISIBLE)
- else
- notificationLayout.setViewVisibility(R.id.etc, View.INVISIBLE)
- snb.setCustomContentView(notificationLayout)
- // Don't know why, but without the delay the notification is not always updated
- Timer().schedule(250) {
- nm.notify(STATUS_NOTIFICATION_ID, buildStatusNotification())
- }
+
updatePartialWakeLock()
}
@SuppressLint("WakelockTimeout")
private fun updatePartialWakeLock() {
val isAnyUaActive = uasStatus.value.values.any { it != R.drawable.circle_white }
- val isAnyCallActive = calls.isNotEmpty()
- val needsToStayAwake = isAnyUaActive || isAnyCallActive
+ val needsToStayAwake = isAnyUaActive || calls.isNotEmpty()
try {
if (needsToStayAwake) {
if (!partialWakeLock.isHeld) {
@@ -1565,6 +1557,7 @@ class BaresipService: Service() {
} catch (e: Exception) {
Log.e(TAG, "Error managing partialWakeLock: ${e.message}")
}
+ updatePartialWakeLock()
}
private fun toast(message: String, length: Int = Toast.LENGTH_SHORT) {
@@ -1573,6 +1566,84 @@ class BaresipService: Service() {
}
}
+ @SuppressLint("FullScreenIntentPolicy")
+ fun handleIncomingCall(call: Call) {
+ val ua = call.ua
+ val peerUri = call.peerUri
+ val callp = call.callp
+
+ val callerNumber = peerUri.split(":")[1].split("@")[0]
+ if (shouldStartRinging(callerNumber))
+ startRinging()
+
+ if (!Utils.isVisible()) {
+ val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ val intent = Intent(applicationContext, MainActivity::class.java)
+ .putExtra("action", "call show")
+ .putExtra("callp", callp)
+ intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+ val pi = PendingIntent.getActivity(applicationContext, CALL_REQ_CODE, intent, piFlags)
+
+ val channelId = HIGH_CHANNEL_ID
+ val nb = NotificationCompat.Builder(this, channelId)
+ val caller = Utils.friendlyUri(this, peerUri, ua.account)
+ val callerContact = Contact.findContact(peerUri)
+ val personBuilder = Person.Builder().setName(caller)
+ val contactColor = callerContact?.color() ?: "#B0B0B0"
+ val initial = if (caller.isNotEmpty()) caller.take(1) else "?"
+ val textAvatarBitmap = Utils.createTextAvatar(initial, contactColor)
+ var icon = IconCompat.createWithBitmap(textAvatarBitmap)
+
+ if (callerContact is Contact.BaresipContact) {
+ if (callerContact.avatarImage != null)
+ icon = IconCompat.createWithBitmap(callerContact.avatarImage!!.toCircle())
+ } else if (callerContact is Contact.AndroidContact) {
+ if (callerContact.thumbnailUri != null) {
+ try {
+ val source = ImageDecoder.createSource(contentResolver, callerContact.thumbnailUri!!)
+ val bitmap = ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ }
+ icon = IconCompat.createWithBitmap(bitmap.toCircle())
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to load Android contact avatar: $e")
+ }
+ }
+ }
+
+ val person = personBuilder.setIcon(icon).build()
+ nb.setSmallIcon(R.drawable.ic_notification_call)
+ .setColor(ContextCompat.getColor(this, R.color.colorPrimary))
+ .setContentIntent(pi)
+ .setCategory(Notification.CATEGORY_CALL)
+ .setAutoCancel(false)
+ .setOngoing(true)
+ .setContentText(getString(R.string.is_calling))
+ .setWhen(System.currentTimeMillis())
+ .setShowWhen(true)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+
+ if (VERSION.SDK_INT < 34 || nm.canUseFullScreenIntent())
+ nb.setFullScreenIntent(pi, true)
+
+ val answerIntent = Intent(applicationContext, MainActivity::class.java)
+ .putExtra("action", "call answer")
+ .putExtra("callp", callp)
+ val api = PendingIntent.getActivity(applicationContext, ANSWER_REQ_CODE, answerIntent, piFlags)
+
+ val rejectIntent = Intent(this, BaresipService::class.java)
+ rejectIntent.action = "Call Reject"
+ rejectIntent.putExtra("callp", callp)
+ val rpi = PendingIntent.getService(this, REJECT_REQ_CODE, rejectIntent, piFlags)
+
+ nb.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, rpi, api))
+ nm.notify(CALL_NOTIFICATION_ID, nb.build())
+ }
+
+ postServiceEvent(ServiceEvent("call incoming", arrayListOf(ua.uap, callp), System.nanoTime()))
+ }
+
private fun startRinging() {
am.mode = AudioManager.MODE_RINGTONE
rt!!.isLooping = true
@@ -1621,6 +1692,7 @@ class BaresipService: Service() {
// First, check if the ringer volume is actually non-zero
if (am.getStreamVolume(AudioManager.STREAM_RING) == 0) return false
// Finally, check the system "Vibrate for calls" setting.
+ // Although deprecated, it is the standard way to check the "Also vibrate for calls" toggle.
return try {
@Suppress("DEPRECATION")
Settings.System.getInt(contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0
@@ -1695,7 +1767,6 @@ class BaresipService: Service() {
stopMediaPlayer()
if (!Call.inCall()) {
resetCallVolume()
- abandonAudioFocus(applicationContext)
proximitySensing(false)
}
}
@@ -1761,15 +1832,9 @@ class BaresipService: Service() {
}
private fun updateNetwork() {
-
- /* for (n in allNetworks)
- Log.d(TAG, "NETWORK $n with caps ${cm.getNetworkCapabilities(n)} and props " +
- "${cm.getLinkProperties(n)} is active ${isNetworkActive(n)}") */
-
updateDnsServers()
val addresses = linkAddresses()
-
Log.d(TAG, "Old/new link addresses $linkAddresses/$addresses")
var added = 0
@@ -1780,6 +1845,7 @@ class BaresipService: Service() {
else
added++
}
+
var removed = 0
for (a in linkAddresses)
if (!addresses.containsKey(a.key)) {
@@ -1895,19 +1961,36 @@ class BaresipService: Service() {
}
}
+ private fun registerPhoneAccount() {
+ val phoneAccountHandle = getPhoneAccountHandle(this)
+ val phoneAccount = android.telecom.PhoneAccount.builder(phoneAccountHandle, getString(R.string.app_name))
+ .setCapabilities(android.telecom.PhoneAccount.CAPABILITY_SELF_MANAGED)
+ .addSupportedUriScheme(android.telecom.PhoneAccount.SCHEME_SIP)
+ .addSupportedUriScheme(android.telecom.PhoneAccount.SCHEME_TEL)
+ .build()
+ tm.registerPhoneAccount(phoneAccount)
+ }
+
private fun cleanService() {
if (!isServiceClean) {
- if (bluetoothReceiverRegistered) {
- this.unregisterReceiver(bluetoothReceiver)
- bluetoothReceiverRegistered = false
+ try {
+ if (hotSpotReceiverRegistered) {
+ applicationContext.unregisterReceiver(hotSpotReceiver)
+ hotSpotReceiverRegistered = false
+ }
+ } catch (_: IllegalArgumentException) {
+ Log.e(TAG, "hotSpotReceiver was not registered with applicationContext")
}
- if (hotSpotReceiverRegistered) {
- unregisterReceiver(hotSpotReceiver)
- hotSpotReceiverRegistered = false
+ val callps = ConnectionService.connections.keys.toList()
+ for (callp in callps)
+ ConnectionService.onCallClosed(callp)
+ ConnectionService.pendingOutgoingConnection?.let {
+ it.setDisconnected(DisconnectCause(DisconnectCause.CANCELED))
+ it.destroy()
+ ConnectionService.pendingOutgoingConnection = null
}
stopRinging()
stopMediaPlayer()
- abandonAudioFocus(applicationContext)
uas.value = emptyList()
uasStatus.value = emptyMap()
callHistory.clear()
@@ -1940,6 +2023,7 @@ class BaresipService: Service() {
@SuppressLint("MutableCollectionMutableState")
companion object {
+ var instance: BaresipService? = null
var isServiceRunning = false
var isStartReceived = false
var isConfigInitialized = false
@@ -1984,7 +2068,6 @@ class BaresipService: Service() {
var dnsServers = listOf()
// of those accounts that have auth username without auth password
val aorPasswords = mutableMapOf()
- var audioFocusRequest: AudioFocusRequestCompat? = null
var aecAvailable = false
private var aec: AcousticEchoCanceler? = null
var agcAvailable = false
@@ -2001,10 +2084,15 @@ class BaresipService: Service() {
private var agc: AutomaticGainControl? = null
private val nsAvailable = NoiseSuppressor.isAvailable()
private var ns: NoiseSuppressor? = null
- private var btAdapter: BluetoothAdapter? = null
private var recorderSessionId = 0
internal const val KEY_TEXT_REPLY = "key_text_reply_baresip"
+ private const val PHONE_ACCOUNT_ID = "baresip_phone_account"
+
+ fun getPhoneAccountHandle(ctx: Context): PhoneAccountHandle {
+ val componentName = android.content.ComponentName(ctx, ConnectionService::class.java)
+ return PhoneAccountHandle(componentName, PHONE_ACCOUNT_ID)
+ }
fun postServiceEvent(event: ServiceEvent) {
serviceEvents.add(event)
@@ -2015,105 +2103,5 @@ class BaresipService: Service() {
Log.d(TAG, "Added service event ${event.event}")
}
}
-
- fun requestAudioFocus(ctx: Context): Boolean {
- Log.d(TAG, "Requesting audio focus")
- if (audioFocusRequest != null) {
- Log.d(TAG, "Already focused")
- return true
- }
- val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager
- val attributes = AudioAttributesCompat.Builder()
- .setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION)
- .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
- .build()
- audioFocusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
- .setAudioAttributes(attributes)
- .setOnAudioFocusChangeListener { }
- .build()
- if (AudioManagerCompat.requestAudioFocus(am, audioFocusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
- Log.d(TAG, "requestAudioFocus granted")
- if (isBluetoothHeadsetConnected(ctx))
- startBluetoothSco(ctx, 250L, 3)
- } else {
- Log.w(TAG, "requestAudioFocus denied")
- audioFocusRequest = null
- }
- return audioFocusRequest != null
- }
-
- fun abandonAudioFocus(ctx: Context) {
- val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager
- if (audioFocusRequest != null) {
- Log.d(TAG, "Abandoning audio focus")
- if (AudioManagerCompat.abandonAudioFocusRequest(am, audioFocusRequest!!) ==
- AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
- audioFocusRequest = null
- if (isBluetoothHeadsetConnected(ctx))
- stopBluetoothSco(ctx)
- } else {
- Log.e(TAG, "Failed to abandon audio focus")
- }
- }
- am.mode = MODE_NORMAL
- }
-
- private fun isBluetoothHeadsetConnected(ctx: Context): Boolean {
- if (VERSION.SDK_INT >= 31 &&
- ActivityCompat.checkSelfPermission(ctx, Manifest.permission.BLUETOOTH_CONNECT) ==
- PackageManager.PERMISSION_DENIED)
- return false
- return btAdapter != null && btAdapter!!.isEnabled &&
- btAdapter!!.getProfileConnectionState(BluetoothHeadset.HEADSET) == BluetoothAdapter.STATE_CONNECTED
- }
-
- private fun isBluetoothScoOn(am: AudioManager): Boolean {
- return if (VERSION.SDK_INT < 31)
- @Suppress("DEPRECATION")
- am.isBluetoothScoOn
- else
- if (am.communicationDevice != null)
- am.communicationDevice!!.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
- else
- false
- }
-
- private fun startBluetoothSco(ctx: Context, delay: Long, count: Int) {
- val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager
- if (isBluetoothScoOn(am)) {
- Log.d(TAG, "Bluetooth SCO is already on")
- return
- }
- Log.d(TAG, "Starting Bluetooth SCO at count $count")
- Handler(Looper.getMainLooper()).postDelayed({
- if (VERSION.SDK_INT < 31) {
- @Suppress("DEPRECATION")
- am.startBluetoothSco()
- } else {
- Utils.setCommunicationDevice(am, AudioDeviceInfo.TYPE_BLUETOOTH_SCO)
- }
- if (!isBluetoothScoOn(am) && count > 1)
- startBluetoothSco(ctx, delay, count - 1)
- else
- am.isBluetoothScoOn = true
- }, delay)
- }
-
- private fun stopBluetoothSco(ctx: Context) {
- Log.d(TAG, "Stopping Bluetooth SCO")
- val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager
- if (!isBluetoothScoOn(am)) {
- Log.d(TAG, "Bluetooth SCO is already off")
- return
- }
- Handler(Looper.getMainLooper()).postDelayed({
- if (VERSION.SDK_INT < 31)
- @Suppress("DEPRECATION")
- am.stopBluetoothSco()
- else
- am.clearCommunicationDevice()
- am.isBluetoothScoOn = false
- }, 100)
- }
}
}
diff --git a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt
new file mode 100644
index 00000000..1329ea8a
--- /dev/null
+++ b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt
@@ -0,0 +1,231 @@
+package com.tutpro.baresip
+
+import android.content.Intent
+import android.telecom.CallAudioState
+import android.telecom.Connection
+import android.telecom.ConnectionRequest
+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() {
+
+ private val TAG = "BaresipConnection"
+
+ companion object {
+ val connections = ConcurrentHashMap()
+ var pendingOutgoingConnection: BaresipConnection? = null
+
+ fun promoteOutgoingConnection(callp: Long) {
+ pendingOutgoingConnection?.let {
+ it.callp = callp
+ connections[callp] = it
+ pendingOutgoingConnection = null
+ }
+ }
+
+ fun onCallClosed(callp: Long) {
+ connections[callp]?.let {
+ it.setDisconnected(DisconnectCause(DisconnectCause.REMOTE))
+ it.destroy()
+ connections.remove(callp)
+ }
+ }
+ }
+
+ override fun onCreateIncomingConnection(
+ connectionManagerPhoneAccount: PhoneAccountHandle?,
+ request: ConnectionRequest?
+ ): Connection {
+ val extras = request?.extras
+ val uap = extras?.getLong("uap") ?: 0L
+ val callp = extras?.getLong("callp") ?: 0L
+ val peerUri = extras?.getString("peerUri") ?: ""
+
+ Log.d(TAG, "onCreateIncomingConnection for $peerUri")
+
+ val connection = BaresipConnection(uap, callp)
+ connections[callp] = connection
+ connection.setAddress(Uri.fromParts("sip", peerUri, null), TelecomManager.PRESENTATION_ALLOWED)
+ connection.connectionCapabilities = Connection.CAPABILITY_SUPPORT_HOLD or Connection.CAPABILITY_HOLD
+
+ val call = Call.ofCallp(callp)
+ if (call != null)
+ BaresipService.instance?.handleIncomingCall(call)
+
+ val ua = UserAgent.ofUap(uap)
+ if (ua != null) {
+ // Check speakerphone setting
+ if (BaresipService.speakerPhone) {
+ @Suppress("DEPRECATION")
+ connection.setAudioRoute(CallAudioState.ROUTE_SPEAKER)
+ }
+
+ // Check for Auto-Answer
+ if (ua.account.answerMode == Api.ANSWERMODE_AUTO) {
+ Log.d(TAG, "Auto-answering call $callp")
+ connection.onAnswer()
+ } else {
+ connection.setRinging()
+ }
+ }
+
+ return connection
+ }
+
+ override fun onCreateIncomingConnectionFailed(
+ connectionManagerPhoneAccount: PhoneAccountHandle?,
+ request: ConnectionRequest?
+ ) {
+ Log.e(TAG, "onCreateIncomingConnectionFailed")
+ }
+
+ override fun onCreateOutgoingConnection(
+ connectionManagerPhoneAccount: PhoneAccountHandle?,
+ request: ConnectionRequest?
+ ): Connection {
+ val rootExtras = request?.extras
+ val nestedExtras = rootExtras?.getBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS)
+
+ // Try root extras first, then fallback to nested
+ val uap = rootExtras?.getLong("uap", 0L).takeIf { it != 0L }
+ ?: nestedExtras?.getLong("uap") ?: 0L
+
+ val conferenceCall = rootExtras?.getBoolean("conferenceCall", false) ?:
+ nestedExtras?.getBoolean("conferenceCall") ?: false
+
+ val onHoldCallp = rootExtras?.getLong("onHoldCallp", 0L).takeIf { it != 0L }
+ ?: nestedExtras?.getLong("onHoldCallp") ?: 0L
+
+ val destination = request?.address?.schemeSpecificPart ?: ""
+
+ Log.d(TAG, "onCreateOutgoingConnection to $destination (uap=$uap)")
+
+ val connection = BaresipConnection(uap, 0L)
+ pendingOutgoingConnection = connection
+
+ if (BaresipService.speakerPhone) {
+ @Suppress("DEPRECATION")
+ connection.setAudioRoute(CallAudioState.ROUTE_SPEAKER)
+ }
+
+ connection.setAddress(request?.address, TelecomManager.PRESENTATION_ALLOWED)
+ connection.connectionCapabilities = Connection.CAPABILITY_SUPPORT_HOLD or Connection.CAPABILITY_HOLD
+
+ // Start the SIP connection logic
+ if (uap != 0L) {
+ 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
+ }
+
+ override fun onCreateOutgoingConnectionFailed(
+ connectionManagerPhoneAccount: PhoneAccountHandle?,
+ request: ConnectionRequest?
+ ) {
+ Log.e(TAG, "onCreateOutgoingConnectionFailed")
+ pendingOutgoingConnection = null
+ }
+
+ inner class BaresipConnection(val uap: Long, var callp: Long) : Connection() {
+
+ override fun onAnswer() {
+ Log.d(TAG, "Telecom Connection onAnswer $callp")
+ val intent = Intent(this@ConnectionService, MainActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.putExtra("action", "call answer")
+ intent.putExtra("callp", callp)
+ startActivity(intent)
+ BaresipService.instance?.updateStatusNotification()
+ setActive()
+ }
+
+ override fun onReject() {
+ Log.d(TAG, "Telecom Connection onReject $callp")
+ Api.ua_hangup(uap, callp, 486, "Rejected")
+ setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
+ connections.remove(callp)
+ destroy()
+ }
+
+ override fun onDisconnect() {
+ Log.d(TAG, "Telecom Connection onDisconnect $callp")
+ if (callp != 0L) {
+ Api.ua_hangup(uap, callp, 0, "")
+ connections.remove(callp)
+ } else {
+ pendingOutgoingConnection = null
+ }
+ setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
+ destroy()
+ }
+
+ override fun onAbort() {
+ Log.d(TAG, "Telecom Connection onAbort $callp")
+ if (callp != 0L) {
+ Api.ua_hangup(uap, callp, 0, "")
+ connections.remove(callp)
+ } else {
+ pendingOutgoingConnection = null
+ }
+ setDisconnected(DisconnectCause(DisconnectCause.CANCELED))
+ destroy()
+ }
+
+ @Deprecated("Deprecated in Java")
+ @Suppress("DEPRECATION")
+ override fun onCallAudioStateChanged(state: CallAudioState?) {
+ super.onCallAudioStateChanged(state)
+ Log.d(TAG, "onCallAudioStateChanged: $state")
+ state?.let {
+ if (BaresipService.isMicMuted != it.isMuted) {
+ BaresipService.isMicMuted = it.isMuted
+ Api.calls_mute(it.isMuted)
+ BaresipService.postServiceEvent(
+ ServiceEvent("mic muted,${it.isMuted}", arrayListOf(uap, callp),
+ System.nanoTime())
+ )
+ }
+ val isSpeaker = it.route == CallAudioState.ROUTE_SPEAKER
+ BaresipService.postServiceEvent(
+ ServiceEvent("speaker,${isSpeaker}", arrayListOf(uap, callp), System.nanoTime())
+ )
+ }
+ }
+
+ override fun onHold() {
+ Log.d(TAG, "Telecom Connection onHold $callp")
+ val c = Call.ofCallp(callp)
+ if (c?.conferenceCall != true)
+ c?.hold()
+ setOnHold()
+ }
+
+ override fun onUnhold() {
+ Log.d(TAG, "Telecom Connection onUnhold $callp")
+ val c = Call.ofCallp(callp)
+ if (c?.conferenceCall != true)
+ c?.resume()
+ setActive()
+ }
+
+ override fun onPlayDtmfTone(c: Char) {
+ Log.d(TAG, "Telecom Connection onPlayDtmfTone $c")
+ if (callp != 0L) {
+ val call = Call.ofCallp(callp)
+ call?.sendDigit(c)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/tutpro/baresip/InCallService.kt b/app/src/main/kotlin/com/tutpro/baresip/InCallService.kt
index ae87ef99..b65e7d0e 100644
--- a/app/src/main/kotlin/com/tutpro/baresip/InCallService.kt
+++ b/app/src/main/kotlin/com/tutpro/baresip/InCallService.kt
@@ -5,11 +5,20 @@ 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? {
- // TODO("Return the communication channel to the service.")
- Log.d("Baresip", "InCallService onBind with intent: ${intent.action}")
+ Log.d(TAG, "InCallService onBind with intent: ${intent.action}")
return null
}
-}
\ No newline at end of file
+}
+
+/*import android.telecom.InCallService
+import android.telecom.Call
+
+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")
+ }
+}*/
diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt
index 0712be38..dc3a437b 100644
--- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt
+++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt
@@ -177,9 +177,6 @@ private var password = mutableStateOf("")
private val selectItems = mutableStateOf(listOf())
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)
- }
- }
-}
diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt
index fe3933f7..200cb427 100644
--- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt
+++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt
@@ -41,6 +41,7 @@ import androidx.core.text.isDigitsOnly
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.navigation.NavController
+import com.tutpro.baresip.Call.Companion.inCall
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
@@ -841,6 +842,12 @@ object Utils {
Configuration.UI_MODE_NIGHT_YES
}
+ fun isPSTNCallActive(ctx: Context): Boolean {
+ // MODE_IN_CALL indicates a PSTN call is active
+ val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ return am.mode == AudioManager.MODE_IN_CALL
+ }
+
fun relativeTime(ctx: Context, time: GregorianCalendar): String {
return if (DateUtils.isToday(time.timeInMillis)) {
val fmt = DateFormat.getTimeInstance(DateFormat.SHORT)
@@ -859,14 +866,6 @@ object Utils {
}
}
- fun isSpeakerPhoneOn(am: AudioManager): Boolean {
- return if (Build.VERSION.SDK_INT >= 31)
- am.communicationDevice!!.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
- else
- @Suppress("DEPRECATION")
- am.isSpeakerphoneOn
- }
-
private fun setSpeakerPhone(executor: Executor, am: AudioManager, enable: Boolean) {
if (Build.VERSION.SDK_INT >= 31) {
val current = am.communicationDevice!!.type
@@ -895,7 +894,7 @@ object Utils {
Log.d(TAG, "Setting com device to TYPE_BUILTIN_EARPIECE")
if (!am.setCommunicationDevice(speakerDevice))
Log.e(TAG, "Could not set com device")
- if (BaresipService.audioFocusRequest != null && am.mode == AudioManager.MODE_NORMAL) {
+ if (inCall() && am.mode == AudioManager.MODE_NORMAL) {
Log.d(TAG, "Setting mode to communication")
am.mode = AudioManager.MODE_IN_COMMUNICATION
}
@@ -945,18 +944,6 @@ object Utils {
}
}
- @RequiresApi(Build.VERSION_CODES.S)
- fun setCommunicationDevice(am: AudioManager, type: Int) {
- val current = am.communicationDevice!!.type
- Log.d(TAG, "Current com dev/mode $current/${am.mode}")
- for (device in am.availableCommunicationDevices)
- if (device.type == type) {
- am.setCommunicationDevice(device)
- break
- }
- Log.d(TAG, "New com dev/mode ${am.communicationDevice!!.type}/${am.mode}")
- }
-
private fun clearCommunicationDevice(am: AudioManager) {
if (Build.VERSION.SDK_INT >= 31) {
am.clearCommunicationDevice()
diff --git a/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt b/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt
index 5ba59dc4..1ab6f5d2 100644
--- a/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt
+++ b/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt
@@ -63,6 +63,9 @@ class ViewModel: ViewModel() {
private val _micIcon = MutableStateFlow(Icons.Filled.Mic)
val micIcon = _micIcon.asStateFlow()
+ private val _isSpeakerOn = MutableStateFlow(false)
+ val isSpeakerOn = _isSpeakerOn.asStateFlow()
+
private val _isDialpadVisible = MutableStateFlow(false)
val isDialpadVisible = _isDialpadVisible.asStateFlow()
@@ -109,6 +112,10 @@ class ViewModel: ViewModel() {
_micIcon.value = icon
}
+ fun updateSpeakerPhoneStatus(on: Boolean) {
+ _isSpeakerOn.value = on
+ }
+
fun toggleDialpadVisibility() {
_isDialpadVisible.value = !_isDialpadVisible.value
}
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 7d501e22..9e428b3e 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -521,6 +521,7 @@
Salli video videon lähetys puhelussa \'%1$s\'\?
Salli video videon vastaanotto puhelussa \'%1$s\'\?
Puhelu on pidossa
+ Puhelu on yhdistetty
Tallennus voidaan asettaa päälle tai pois vain silloin, kun puhelu
ei ole yhdistetty
Puhelun siirto
@@ -530,6 +531,7 @@
Valitse kohteen URI
Siirto
Siirto epäonnistui
+ Toinen osapuoli ei tue REPLACES ominaisuutta
DTMF
Puhelutiedot
Ei saatavilla
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 85e47b57..9c5d38a8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -505,6 +505,7 @@
Accept sending of video to \'%1$s\'\?
Accept receiving video from \'%1$s\'\?
Call is on hold
+ Call is connected
Recording can be turned on or off only when call is not
connected
Call Transfer
@@ -514,6 +515,7 @@
Choose destination URI
Transfer
Transfer failed
+ Peer does not support REPLACES feature
DTMF
Call Info
No info available