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