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