From 9c02de17cf49375d584fdc88602cf74e633504c7 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Mon, 30 Mar 2026 18:02:52 +0300 Subject: [PATCH 01/14] Work on calling app --- app/src/main/AndroidManifest.xml | 18 +- .../com/tutpro/baresip/BaresipService.kt | 640 +++++++++--------- .../kotlin/com/tutpro/baresip/MainScreen.kt | 253 ++++--- .../kotlin/com/tutpro/baresip/ViewModel.kt | 7 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 466 insertions(+), 453 deletions(-) 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 isServiceClean = false @@ -127,6 +120,7 @@ class BaresipService: Service() { super.onCreate() Log.i(TAG, "BaresipService onCreate") + instance = this intent = Intent("com.tutpro.baresip.EVENT") intent.setPackage("com.tutpro.baresip") @@ -272,9 +266,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") @@ -287,71 +279,6 @@ class BaresipService: Service() { 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") @@ -559,6 +486,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) @@ -679,6 +618,7 @@ class BaresipService: Service() { super.onDestroy() Log.d(TAG, "onDestroy at Baresip Service") cleanService() + instance = null if (isServiceRunning) sendBroadcast(Intent("com.tutpro.baresip.Restart")) } @@ -838,19 +778,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" -> { @@ -910,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() @@ -1001,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 @@ -1085,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() @@ -1107,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") @@ -1405,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() { @@ -1414,7 +1332,7 @@ class BaresipService: Service() { callActionUri = "" Log.d(TAG, "Battery optimizations are ignored: " + "${pm.isIgnoringBatteryOptimizations(packageName)}") - Log.d(TAG, "Wifi lock is held: ${wifiLock.isHeld}") + Log.d(TAG, "WiFi lock is held: ${wifiLock.isHeld}") updateStatusNotification() } @@ -1460,7 +1378,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 @@ -1470,8 +1387,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) @@ -1484,26 +1401,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) { @@ -1511,39 +1420,194 @@ 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 - } - if (i < uas.value.size) { - notificationLayout.setImageViewResource(resId, uas.value[i].status) - notificationLayout.setViewVisibility(resId, View.VISIBLE) - } else { - notificationLayout.setViewVisibility(resId, View.INVISIBLE) - } + fun updateStatusNotification() { + val activeCall = Call.calls().find { + it.status.value == "connected" || it.status.value == "outgoing" || it.status.value == "answered" } - 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()) + + 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 (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 + + 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) + } else { + stopForeground(STOP_FOREGROUND_REMOVE) + 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) { + Log.e(TAG, "Failed to update foreground notification: ${e.message}") + nm.notify(STATUS_NOTIFICATION_ID, notification) } } - + private fun toast(message: String, length: Int = Toast.LENGTH_SHORT) { Handler(Looper.getMainLooper()).post { Toast.makeText(this@BaresipService.applicationContext, message, length).show() } } + @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 @@ -1592,6 +1656,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 @@ -1666,7 +1731,6 @@ class BaresipService: Service() { stopMediaPlayer() if (!Call.inCall()) { resetCallVolume() - abandonAudioFocus(applicationContext) proximitySensing(false) } } @@ -1732,15 +1796,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 @@ -1751,6 +1809,7 @@ class BaresipService: Service() { else added++ } + var removed = 0 for (a in linkAddresses) if (!addresses.containsKey(a.key)) { @@ -1771,16 +1830,25 @@ class BaresipService: Service() { Api.net_debug() - if (activeNetwork != null) { - val caps = cm.getNetworkCapabilities(activeNetwork) - if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + // Robust Wi-Fi Lock Logic: + // Check ALL networks, not just the 'active' one, to ensure we keep the radio + // awake if Wi-Fi is available at all. + val hasWifi = allNetworks.any { network -> + val caps = cm.getNetworkCapabilities(network) + caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + + if (hasWifi) { + if (!wifiLock.isHeld) { Log.d(TAG, "Acquiring WiFi Lock") wifiLock.acquire() - return + } + } else { + if (wifiLock.isHeld) { + Log.d(TAG, "Releasing WiFi Lock") + wifiLock.release() } } - Log.d(TAG, "Releasing WiFi Lock") - wifiLock.release() } private fun linkAddresses(): MutableMap { @@ -1860,19 +1928,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() @@ -1903,6 +1988,7 @@ class BaresipService: Service() { @SuppressLint("MutableCollectionMutableState") companion object { + var instance: BaresipService? = null var isServiceRunning = false var isStartReceived = false var isConfigInitialized = false @@ -1964,10 +2050,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) @@ -1996,8 +2087,6 @@ class BaresipService: Service() { .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 @@ -2005,78 +2094,5 @@ class BaresipService: Service() { 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/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index 267b3e0e..d78798c6 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, @@ -536,7 +533,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) @@ -655,11 +652,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 @@ -671,7 +679,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, @@ -891,8 +899,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 } @@ -1490,12 +1497,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( @@ -1516,9 +1524,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( @@ -1532,19 +1544,26 @@ private fun CallRow( 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}" ) - call.resume() + if (connection != null) + connection.onUnhold() + else + call.resume() call.onhold = false } else { Log.d( TAG, "AoR ${call.ua.account.aor} holding call ${call.callp} with ${call.callUri.value}" ) - call.hold() + if (connection != null) + connection.onHold() + else + call.hold() call.onhold = true } }, @@ -1568,12 +1587,15 @@ private fun CallRow( enabled = call.transferButtonEnabled.value, onClick = { if (call.onHoldCall != null) { + 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 + } + else showTransferDialog = true }, ) { @@ -1970,16 +1992,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 + ) + } } } @@ -2020,10 +2044,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 @@ -2044,44 +2068,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 (!BaresipService.requestAudioFocus(ctx)) - Toast.makeText(ctx, R.string.audio_focus_denied, Toast.LENGTH_SHORT).show() else if (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 - 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) @@ -2091,59 +2102,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") } } @@ -2161,12 +2125,22 @@ 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) } } 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) @@ -2213,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 @@ -2381,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) @@ -2457,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 @@ -2498,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]}'") } @@ -2777,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/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/strings.xml b/app/src/main/res/values/strings.xml index 85e47b57..b40ea5ff 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 From 994db0bd348a35ccfc8513b19c1d284b1a69f73f Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Tue, 31 Mar 2026 08:31:44 +0300 Subject: [PATCH 02/14] Added isNotificationInCall variable --- .../com/tutpro/baresip/BaresipService.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 7e451993..6ef7f58b 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -113,6 +113,7 @@ class BaresipService: Service() { private var mediaPlayer: MediaPlayer? = null private var androidContactsObserverRegistered = false private var hotSpotReceiverRegistered = false + private var isNotificationInCall = false private var isServiceClean = false @SuppressLint("WakelockTimeout") @@ -1495,6 +1496,7 @@ class BaresipService: Service() { 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 @@ -1510,13 +1512,23 @@ class BaresipService: Service() { startForeground(STATUS_NOTIFICATION_ID, notification, type) else startForeground(STATUS_NOTIFICATION_ID, notification) + isNotificationInCall = true } else { - stopForeground(STOP_FOREGROUND_REMOVE) - if (VERSION.SDK_INT >= 34) - startForeground(STATUS_NOTIFICATION_ID, notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) - else - startForeground(STATUS_NOTIFICATION_ID, notification) + 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}") From e63a3319d62c87be568df87b7033a0bc3a8b79fc Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Tue, 31 Mar 2026 08:47:18 +0300 Subject: [PATCH 03/14] Added ConnectionService.kt --- .../com/tutpro/baresip/ConnectionService.kt | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt 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..fe8df5fb --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt @@ -0,0 +1,227 @@ +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") + Api.call_hold(callp, true) + setOnHold() + } + + override fun onUnhold() { + Log.d(TAG, "Telecom Connection onUnhold $callp") + Api.call_hold(callp, false) + setActive() + } + + override fun onPlayDtmfTone(c: Char) { + Log.d(TAG, "Telecom Connection onPlayDtmfTone $c") + if (callp != 0L) { + val call = Call.ofCallp(callp) + call?.sendDigit(c) + } + } + } +} From 0b867fa8f677063424d7070181185b658fbe7bfe Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Wed, 1 Apr 2026 10:12:48 +0300 Subject: [PATCH 04/14] Added comment to InCallService --- .../kotlin/com/tutpro/baresip/InCallService.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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") + } +}*/ From 4563d625712700ffcca969399236af9c6473b0f1 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Wed, 1 Apr 2026 10:13:24 +0300 Subject: [PATCH 05/14] Acquire partial wake lock only if there is active registrations or calls --- .../com/tutpro/baresip/BaresipService.kt | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 6ef7f58b..a3972aff 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -95,6 +95,7 @@ class BaresipService: Service() { private lateinit var wm: WifiManager private lateinit var tm: TelecomManager private lateinit var vibrator: Vibrator + private lateinit var partialWakeLock: PowerManager.WakeLock private lateinit var proximityWakeLock: PowerManager.WakeLock private lateinit var wifiLock: WifiManager.WifiLock private lateinit var hotSpotReceiver: BroadcastReceiver @@ -155,6 +156,12 @@ class BaresipService: Service() { applicationContext.getSystemService(VIBRATOR_SERVICE) as Vibrator } + // This is needed to keep service running also in Doze Mode + partialWakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "com.tutpro.baresip:wakelock" + ).apply { setReferenceCounted(false) } + networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { @@ -1333,7 +1340,8 @@ class BaresipService: Service() { callActionUri = "" Log.d(TAG, "Battery optimizations are ignored: " + "${pm.isIgnoringBatteryOptimizations(packageName)}") - Log.d(TAG, "WiFi lock is held: ${wifiLock.isHeld}") + Log.d(TAG, "Partial wake lock/wifi lock is held: " + + "${partialWakeLock.isHeld}/${wifiLock.isHeld}") updateStatusNotification() } @@ -1534,8 +1542,32 @@ class BaresipService: Service() { Log.e(TAG, "Failed to update foreground notification: ${e.message}") nm.notify(STATUS_NOTIFICATION_ID, notification) } + + 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 + try { + if (needsToStayAwake) { + if (!partialWakeLock.isHeld) { + Log.d(TAG, "Acquiring Partial Wake Lock (UA Active or Call in progress)") + partialWakeLock.acquire() + } + } else { + if (partialWakeLock.isHeld) { + Log.d(TAG, "Releasing Partial Wake Lock (All UAs idle and no calls)") + partialWakeLock.release() + } + } + } catch (e: Exception) { + Log.e(TAG, "Error managing partialWakeLock: ${e.message}") + } + } + private fun toast(message: String, length: Int = Toast.LENGTH_SHORT) { Handler(Looper.getMainLooper()).post { Toast.makeText(this@BaresipService.applicationContext, message, length).show() @@ -1976,6 +2008,8 @@ class BaresipService: Service() { messages = emptyList() if (this::nm.isInitialized) nm.cancelAll() + if (this::partialWakeLock.isInitialized && partialWakeLock.isHeld) + partialWakeLock.release() if (this::proximityWakeLock.isInitialized && proximityWakeLock.isHeld) proximityWakeLock.release() if (this::wifiLock.isInitialized) From 0aca41a956ac0088e1860ba7b9d40a614fde7421 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Wed, 1 Apr 2026 13:10:15 +0300 Subject: [PATCH 06/14] Print network debug only if there was change --- app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index a3972aff..987c640a 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -284,7 +284,6 @@ 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) androidContactsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { @@ -1867,16 +1866,12 @@ class BaresipService: Service() { Log.d(TAG, "Added/Removed/Old/New Active = $added/$removed/$activeNetwork/$active") if (added > 0 || removed > 0 || active != activeNetwork) { + Api.net_debug() linkAddresses = addresses activeNetwork = active Api.uag_reset_transp(register = true, reinvite = true) } - Api.net_debug() - - // Robust Wi-Fi Lock Logic: - // Check ALL networks, not just the 'active' one, to ensure we keep the radio - // awake if Wi-Fi is available at all. val hasWifi = allNetworks.any { network -> val caps = cm.getNetworkCapabilities(network) caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) From d20b1adcc20c4c589da732f8883b9c02e0a289dc Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Wed, 1 Apr 2026 17:16:12 +0300 Subject: [PATCH 07/14] Always resume to active call on main screen if there is one --- .../kotlin/com/tutpro/baresip/MainScreen.kt | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index d78798c6..8ddd8d75 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -231,19 +231,12 @@ private fun MainScreen( Lifecycle.Event.ON_RESUME -> { Log.d(TAG, "Resumed to MainScreen") BaresipService.isMainVisible = true - val incomingCall = Call.call("incoming") viewModel.updateCalls(Call.calls().toList()) - if (incomingCall != null) - spinToAor(viewModel, incomingCall.ua.account.aor) - else { - if (uas.value.isNotEmpty()) { - if (viewModel.selectedAor.value == "") { - if (Call.inCall()) - spinToAor(viewModel, Call.calls().last().ua.account.aor) - else - spinToAor(viewModel, uas.value.first().account.aor) - } - } + (Call.call("incoming") ?: Call.calls().lastOrNull())?.let { + spinToAor(viewModel, it.ua.account.aor) + } ?: run { + if (uas.value.isNotEmpty() && viewModel.selectedAor.value == "") + spinToAor(viewModel, uas.value.first().account.aor) } val ua = UserAgent.ofAor(viewModel.selectedAor.value) if (ua != null) { From f64e1bd9821acb3f3e4596a55f801c7a96f91e30 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Thu, 2 Apr 2026 09:12:07 +0300 Subject: [PATCH 08/14] Improved checking if there already is an active call --- .../com/tutpro/baresip/BaresipService.kt | 37 ++----------------- .../main/kotlin/com/tutpro/baresip/Call.kt | 9 +++++ .../kotlin/com/tutpro/baresip/MainScreen.kt | 2 +- 3 files changed, 13 insertions(+), 35 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 987c640a..58a38cd5 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -65,9 +65,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData -import androidx.media.AudioAttributesCompat import androidx.media.AudioFocusRequestCompat -import androidx.media.AudioManagerCompat import com.tutpro.baresip.Utils.toCircle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -804,18 +802,14 @@ class BaresipService: Service() { "incoming call" -> { val peerUri = ev[1] val bevent = ev[2].toLong() - val blockUnknown = ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri - val toastMsg = if (Call.inCall()) + val toastMsg = if (Call.isAnyCallActive(applicationContext)) String.format(getString(R.string.call_auto_rejected), Utils.friendlyUri(this, peerUri, ua.account)) - else if (blockUnknown) + else if (ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri) String.format(getString(R.string.call_blocked), Utils.friendlyUri(this, peerUri, ua.account)) else if (!Utils.checkPermissions(this, arrayOf(RECORD_AUDIO))) getString(R.string.no_calls) - else if (!requestAudioFocus(applicationContext)) - // request fails if there is an active telephony call - getString(R.string.audio_focus_denied) else "" if (toastMsg != "") { @@ -823,7 +817,7 @@ class BaresipService: Service() { Api.sip_treply(callp, 486, "Busy Here") Api.bevent_stop(bevent) toast(toastMsg) - if (blockUnknown) { + if (toastMsg.contains(getString(R.string.call_blocked))) { if (ua.account.callHistory) Blocked( ua.account.aor, @@ -2110,30 +2104,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") - } else { - Log.w(TAG, "requestAudioFocus denied") - audioFocusRequest = null - } - return audioFocusRequest != null - } - } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/Call.kt b/app/src/main/kotlin/com/tutpro/baresip/Call.kt index de38f6d6..3dff670e 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Call.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Call.kt @@ -1,5 +1,7 @@ package com.tutpro.baresip +import android.content.Context +import android.media.AudioManager import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -143,5 +145,12 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str return BaresipService.calls.isNotEmpty() } + fun isAnyCallActive(ctx: Context): Boolean { + // Check SIP calls managed by baresip + if (inCall()) return true + // 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 + } } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index 8ddd8d75..cfd5717d 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -2061,7 +2061,7 @@ 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.calls().any { it.ua.account.aor != ua.account.aor }) + else if (Call.isAnyCallActive(ctx)) Toast.makeText(ctx, R.string.call_already_active, Toast.LENGTH_SHORT).show() else { val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as android.telecom.TelecomManager From eb52a1a8d3badfdccad2115a76793fc2f9d9f73c Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Thu, 2 Apr 2026 09:56:47 +0300 Subject: [PATCH 09/14] Simplified audio management --- .../com/tutpro/baresip/BaresipService.kt | 2 -- .../main/kotlin/com/tutpro/baresip/Utils.kt | 22 +------------------ 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 58a38cd5..55c8717c 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -65,7 +65,6 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData -import androidx.media.AudioFocusRequestCompat import com.tutpro.baresip.Utils.toCircle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -2068,7 +2067,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 diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index fe3933f7..8fea284f 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -859,14 +859,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 +887,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 (Call.inCall() && am.mode == AudioManager.MODE_NORMAL) { Log.d(TAG, "Setting mode to communication") am.mode = AudioManager.MODE_IN_COMMUNICATION } @@ -945,18 +937,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() From 91f28f03df9540a52bee9ff8486796bcb85ee3f5 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Thu, 2 Apr 2026 19:04:08 +0300 Subject: [PATCH 10/14] Fixed bug in checking if call already exists --- .../kotlin/com/tutpro/baresip/BaresipService.kt | 5 ++--- app/src/main/kotlin/com/tutpro/baresip/Call.kt | 10 ---------- .../kotlin/com/tutpro/baresip/ConnectionService.kt | 14 ++++++++++++-- .../main/kotlin/com/tutpro/baresip/MainScreen.kt | 2 +- app/src/main/kotlin/com/tutpro/baresip/Utils.kt | 13 ++++++++++++- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 55c8717c..cae4e965 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -801,7 +801,7 @@ class BaresipService: Service() { "incoming call" -> { val peerUri = ev[1] val bevent = ev[2].toLong() - val toastMsg = if (Call.isAnyCallActive(applicationContext)) + val toastMsg = if (Utils.isAnyCallActive(applicationContext)) String.format(getString(R.string.call_auto_rejected), Utils.friendlyUri(this, peerUri, ua.account)) else if (ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri) @@ -1541,8 +1541,7 @@ class BaresipService: Service() { @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) { diff --git a/app/src/main/kotlin/com/tutpro/baresip/Call.kt b/app/src/main/kotlin/com/tutpro/baresip/Call.kt index 3dff670e..8f74a7a0 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Call.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Call.kt @@ -1,7 +1,5 @@ package com.tutpro.baresip -import android.content.Context -import android.media.AudioManager import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -144,13 +142,5 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str fun inCall(): Boolean { return BaresipService.calls.isNotEmpty() } - - fun isAnyCallActive(ctx: Context): Boolean { - // Check SIP calls managed by baresip - if (inCall()) return true - // 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 - } } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt index fe8df5fb..36a61d02 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt @@ -206,13 +206,23 @@ class ConnectionService : ConnectionService() { override fun onHold() { Log.d(TAG, "Telecom Connection onHold $callp") - Api.call_hold(callp, true) + val call = Call.ofCallp(callp) + if (call == null || !call.conferenceCall) { + Api.call_hold(callp, true) + } else { + Log.d(TAG, "Call $callp is in conference, skipping baresip hold") + } setOnHold() } override fun onUnhold() { Log.d(TAG, "Telecom Connection onUnhold $callp") - Api.call_hold(callp, false) + val call = Call.ofCallp(callp) + if (call == null || !call.conferenceCall) { + Api.call_hold(callp, false) + } else { + Log.d(TAG, "Call $callp is in conference, skipping baresip resume") + } setActive() } diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index cfd5717d..111af184 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -2061,7 +2061,7 @@ 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 { val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as android.telecom.TelecomManager diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 8fea284f..97e3431c 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,16 @@ object Utils { Configuration.UI_MODE_NIGHT_YES } + fun isAnyCallActive(ctx: Context): Boolean { + return inCall() || isPSTNCallActive(ctx) + } + + 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) @@ -887,7 +898,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 (Call.inCall() && 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 } From bdaee6f013614ce3a9738b533c479dbad2fafabf Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Fri, 3 Apr 2026 13:11:06 +0300 Subject: [PATCH 11/14] Call hold/resume improvements --- .../com/tutpro/baresip/BaresipService.kt | 2 +- .../main/kotlin/com/tutpro/baresip/Call.kt | 18 ++++- .../com/tutpro/baresip/ConnectionService.kt | 18 ++--- .../kotlin/com/tutpro/baresip/MainScreen.kt | 72 +++++++++---------- .../main/kotlin/com/tutpro/baresip/Utils.kt | 4 -- 5 files changed, 59 insertions(+), 55 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index cae4e965..eb0fe7d6 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -801,7 +801,7 @@ class BaresipService: Service() { "incoming call" -> { val peerUri = ev[1] val bevent = ev[2].toLong() - val toastMsg = if (Utils.isAnyCallActive(applicationContext)) + val toastMsg = if (Call.isAnyCallActive(applicationContext)) String.format(getString(R.string.call_auto_rejected), Utils.friendlyUri(this, peerUri, ua.account)) else if (ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri) diff --git a/app/src/main/kotlin/com/tutpro/baresip/Call.kt b/app/src/main/kotlin/com/tutpro/baresip/Call.kt index 8f74a7a0..3762fb6e 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Call.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Call.kt @@ -1,5 +1,7 @@ package com.tutpro.baresip +import android.content.Context +import android.media.AudioManager import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -55,11 +57,15 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str } fun hold(): Boolean { - return Api.call_hold(callp, true) == 0 + if (Api.call_hold(callp, true) == 0) + onhold = true + return onhold } fun resume(): Boolean { - return Api.call_hold(callp, false) == 0 + if (Api.call_hold(callp, false) == 0) + onhold = false + return !onhold } fun transfer(uri: String): Boolean { @@ -142,5 +148,13 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str fun inCall(): Boolean { return BaresipService.calls.isNotEmpty() } + + fun isAnyCallActive(ctx: Context): Boolean { + // Check if there exist SIP calls that are not onhold or held + if (BaresipService.calls.any { !it.onhold && !it.held }) return true + // 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 + } } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt index 36a61d02..6019b658 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt @@ -206,23 +206,17 @@ class ConnectionService : ConnectionService() { override fun onHold() { Log.d(TAG, "Telecom Connection onHold $callp") - val call = Call.ofCallp(callp) - if (call == null || !call.conferenceCall) { - Api.call_hold(callp, true) - } else { - Log.d(TAG, "Call $callp is in conference, skipping baresip hold") - } + val c = Call.ofCallp(callp) + if (c?.conferenceCall != true) + c?.hold() setOnHold() } override fun onUnhold() { Log.d(TAG, "Telecom Connection onUnhold $callp") - val call = Call.ofCallp(callp) - if (call == null || !call.conferenceCall) { - Api.call_hold(callp, false) - } else { - Log.d(TAG, "Call $callp is in conference, skipping baresip resume") - } + val c = Call.ofCallp(callp) + if (c?.conferenceCall != true) + c?.resume() setActive() } diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index 111af184..7ae3d0dc 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -1534,43 +1534,42 @@ private fun CallRow( ) } - 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() + 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 - call.resume() - call.onhold = false - } 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() - call.onhold = true - } - }, - ) { - Icon( - imageVector = Icons.Outlined.PauseCircle, - modifier = Modifier.size(42.dp), - tint = if (call.callOnHold.value) - MaterialTheme.colorScheme.error - else - MaterialTheme.colorScheme.secondary, - contentDescription = null, - ) - } + MaterialTheme.colorScheme.secondary, + contentDescription = null, + ) + } var showTransferDialog by remember { mutableStateOf(false) } @@ -2129,6 +2128,7 @@ private fun transfer(ctx: Context, viewModel: ViewModel, ua: UserAgent, uriText: call.onhold = true call.referTo = uri makeCall(ctx, viewModel, uri, false, call.callp) + showCall(ctx, viewModel, ua, call) } } else { diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 97e3431c..200cb427 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -842,10 +842,6 @@ object Utils { Configuration.UI_MODE_NIGHT_YES } - fun isAnyCallActive(ctx: Context): Boolean { - return inCall() || isPSTNCallActive(ctx) - } - fun isPSTNCallActive(ctx: Context): Boolean { // MODE_IN_CALL indicates a PSTN call is active val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager From cc3fc1b23db61fe17830fad622bf88a50edbf69e Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Fri, 3 Apr 2026 14:13:58 +0300 Subject: [PATCH 12/14] In attended call transfer, check if peer supports REPLACES header --- app/src/main/cpp/baresip.c | 8 ++++++++ app/src/main/kotlin/com/tutpro/baresip/Api.kt | 2 ++ app/src/main/kotlin/com/tutpro/baresip/Call.kt | 2 ++ .../kotlin/com/tutpro/baresip/MainScreen.kt | 17 ++++++++++++----- app/src/main/res/values-fi/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/src/main/cpp/baresip.c b/app/src/main/cpp/baresip.c index ccb915d3..af1da6c4 100644 --- a/app/src/main/cpp/baresip.c +++ b/app/src/main/cpp/baresip.c @@ -1414,6 +1414,14 @@ JNIEXPORT jboolean JNICALL Java_com_tutpro_baresip_Api_call_1ismuted( return audio_ismuted(call_audio((struct call *)call)); } +JNIEXPORT jboolean JNICALL Java_com_tutpro_baresip_Api_call_1supported( + JNIEnv *env, jobject obj, jlong call, jint tags) +{ + (void)env; + (void)obj; + return call_supported((struct call *)call, tags); +} + JNIEXPORT jint JNICALL Java_com_tutpro_baresip_Api_call_1transfer( JNIEnv *env, jobject obj, jlong call, jstring jPeer) { diff --git a/app/src/main/kotlin/com/tutpro/baresip/Api.kt b/app/src/main/kotlin/com/tutpro/baresip/Api.kt index ffe97ab6..3fbe7c42 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Api.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Api.kt @@ -104,6 +104,8 @@ object Api { external fun call_replace_transfer(xfer_callp: Long, callp: Long): Boolean external fun call_peer_uri(callp: Long): String external fun call_diverter_uri(callp: Long): String + + external fun call_supported(callp: Long, header: Int): Boolean external fun call_destroy(callp: Long) external fun calls_mute(mute: Boolean) diff --git a/app/src/main/kotlin/com/tutpro/baresip/Call.kt b/app/src/main/kotlin/com/tutpro/baresip/Call.kt index 3762fb6e..6780541e 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Call.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Call.kt @@ -129,6 +129,8 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str companion object { + const val REPLACES = 1 + fun calls(): ArrayList { return BaresipService.calls } diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index 7ae3d0dc..ceb55ec9 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -1579,13 +1579,20 @@ private fun CallRow( enabled = call.transferButtonEnabled.value, onClick = { if (call.onHoldCall != null) { - 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) + if (!Api.call_supported(call.callp, Call.REPLACES)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.replaces_not_supported) showAlert.value = true } + 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 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 b40ea5ff..9c5d38a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -515,6 +515,7 @@ Choose destination URI Transfer Transfer failed + Peer does not support REPLACES feature DTMF Call Info No info available From 12d53da16cb0a0b6d1bea0fb67f89646b525d549 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Fri, 3 Apr 2026 14:42:57 +0300 Subject: [PATCH 13/14] Moved REPLACES constant to Api --- app/src/main/kotlin/com/tutpro/baresip/Api.kt | 2 ++ app/src/main/kotlin/com/tutpro/baresip/Call.kt | 2 -- app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/Api.kt b/app/src/main/kotlin/com/tutpro/baresip/Api.kt index 3fbe7c42..f778cab8 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Api.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Api.kt @@ -20,6 +20,8 @@ object Api { const val CALL_STATE_EARLY = 4 + const val REPLACES = 1 + external fun account_set_display_name(acc: Long, dn: String): Int external fun account_display_name(acc: Long): String external fun account_aor(acc: Long): String diff --git a/app/src/main/kotlin/com/tutpro/baresip/Call.kt b/app/src/main/kotlin/com/tutpro/baresip/Call.kt index 6780541e..3762fb6e 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Call.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Call.kt @@ -129,8 +129,6 @@ class Call(val callp: Long, val ua: UserAgent, val peerUri: String, val dir: Str companion object { - const val REPLACES = 1 - fun calls(): ArrayList { return BaresipService.calls } diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index ceb55ec9..d2f6cc3c 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -1579,7 +1579,7 @@ private fun CallRow( enabled = call.transferButtonEnabled.value, onClick = { if (call.onHoldCall != null) { - if (!Api.call_supported(call.callp, Call.REPLACES)) { + 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 From 3baef81b0e1696e95a59abb251ca7c69d896f406 Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Fri, 3 Apr 2026 14:57:08 +0300 Subject: [PATCH 14/14] Removed extra spaces --- app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt index 6019b658..1329ea8a 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ConnectionService.kt @@ -55,7 +55,7 @@ class ConnectionService : ConnectionService() { val call = Call.ofCallp(callp) if (call != null) BaresipService.instance?.handleIncomingCall(call) - + val ua = UserAgent.ofUap(uap) if (ua != null) { // Check speakerphone setting