Work on calling app

This commit is contained in:
Juha Heinanen
2026-03-30 18:02:52 +03:00
parent 2a38408b76
commit 9c02de17cf
5 changed files with 466 additions and 453 deletions
+17 -1
View File
@@ -9,6 +9,7 @@
tools:ignore="ForegroundServicesPolicy" /> tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -25,6 +26,8 @@
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" /> <uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" /> <uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" tools:ignore="FullScreenIntentPolicy" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" tools:ignore="FullScreenIntentPolicy" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -83,8 +86,21 @@
<service <service
android:name=".BaresipService" android:name=".BaresipService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="microphone|phoneCall" android:foregroundServiceType="microphone|phoneCall|specialUse"
android:stopWithTask="false" > android:stopWithTask="false" >
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintaining SIP registration for incoming VoIP calls" />
</service>
<service
android:name=".ConnectionService"
android:enabled="true"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE" >
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service> </service>
<service <service
@@ -1,6 +1,5 @@
package com.tutpro.baresip package com.tutpro.baresip
import android.Manifest
import android.Manifest.permission.RECORD_AUDIO import android.Manifest.permission.RECORD_AUDIO
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
@@ -8,24 +7,20 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
import android.content.res.Configuration import android.content.res.Configuration
import android.database.ContentObserver import android.database.ContentObserver
import android.graphics.ImageDecoder import android.graphics.ImageDecoder
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioManager.MODE_IN_COMMUNICATION import android.media.AudioManager.MODE_IN_COMMUNICATION
import android.media.AudioManager.MODE_NORMAL
import android.media.MediaPlayer import android.media.MediaPlayer
import android.media.Ringtone import android.media.Ringtone
import android.media.RingtoneManager import android.media.RingtoneManager
@@ -51,6 +46,8 @@ import android.os.VibratorManager
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.Settings import android.provider.Settings
import android.system.OsConstants import android.system.OsConstants
import android.telecom.DisconnectCause
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.view.View import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
@@ -60,7 +57,6 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle import androidx.core.app.NotificationCompat.MessagingStyle
import androidx.core.app.Person import androidx.core.app.Person
@@ -98,11 +94,9 @@ class BaresipService: Service() {
private lateinit var pm: PowerManager private lateinit var pm: PowerManager
private lateinit var wm: WifiManager private lateinit var wm: WifiManager
private lateinit var tm: TelecomManager private lateinit var tm: TelecomManager
private lateinit var btm: BluetoothManager
private lateinit var vibrator: Vibrator private lateinit var vibrator: Vibrator
private lateinit var proximityWakeLock: PowerManager.WakeLock private lateinit var proximityWakeLock: PowerManager.WakeLock
private lateinit var wifiLock: WifiManager.WifiLock private lateinit var wifiLock: WifiManager.WifiLock
private lateinit var bluetoothReceiver: BroadcastReceiver
private lateinit var hotSpotReceiver: BroadcastReceiver private lateinit var hotSpotReceiver: BroadcastReceiver
private lateinit var androidContactsObserver: ContentObserver private lateinit var androidContactsObserver: ContentObserver
private lateinit var networkCallback: ConnectivityManager.NetworkCallback private lateinit var networkCallback: ConnectivityManager.NetworkCallback
@@ -118,7 +112,6 @@ class BaresipService: Service() {
private var hotSpotAddresses = mapOf<String, String>() private var hotSpotAddresses = mapOf<String, String>()
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaPlayer? = null
private var androidContactsObserverRegistered = false private var androidContactsObserverRegistered = false
private var bluetoothReceiverRegistered = false
private var hotSpotReceiverRegistered = false private var hotSpotReceiverRegistered = false
private var isServiceClean = false private var isServiceClean = false
@@ -127,6 +120,7 @@ class BaresipService: Service() {
super.onCreate() super.onCreate()
Log.i(TAG, "BaresipService onCreate") Log.i(TAG, "BaresipService onCreate")
instance = this
intent = Intent("com.tutpro.baresip.EVENT") intent = Intent("com.tutpro.baresip.EVENT")
intent.setPackage("com.tutpro.baresip") intent.setPackage("com.tutpro.baresip")
@@ -272,9 +266,7 @@ class BaresipService: Service() {
hotSpotReceiverRegistered = true hotSpotReceiverRegistered = true
tm = getSystemService(TELECOM_SERVICE) as TelecomManager tm = getSystemService(TELECOM_SERVICE) as TelecomManager
registerPhoneAccount()
btm = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
btAdapter = btm.adapter
proximityWakeLock = pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, proximityWakeLock = pm.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
"com.tutpro.baresip:proximity_wakelock") "com.tutpro.baresip:proximity_wakelock")
@@ -287,71 +279,6 @@ class BaresipService: Service() {
wifiLock.setReferenceCounted(false) 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())) { androidContactsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(self: Boolean) { override fun onChange(self: Boolean) {
Log.d(TAG, "Android contacts change") 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" -> { "Transfer Deny" -> {
val callp = intent!!.getLongExtra("callp", 0L) val callp = intent!!.getLongExtra("callp", 0L)
val call = Call.ofCallp(callp) val call = Call.ofCallp(callp)
@@ -679,6 +618,7 @@ class BaresipService: Service() {
super.onDestroy() super.onDestroy()
Log.d(TAG, "onDestroy at Baresip Service") Log.d(TAG, "onDestroy at Baresip Service")
cleanService() cleanService()
instance = null
if (isServiceRunning) if (isServiceRunning)
sendBroadcast(Intent("com.tutpro.baresip.Restart")) sendBroadcast(Intent("com.tutpro.baresip.Restart"))
} }
@@ -838,19 +778,20 @@ class BaresipService: Service() {
break break
stopMediaPlayer() stopMediaPlayer()
setCallVolume() setCallVolume()
if (speakerPhone && !Utils.isSpeakerPhoneOn(am))
Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(this), am)
proximitySensing(proximitySensing) proximitySensing(proximitySensing)
} }
"call ringing" -> { "call ringing" -> {
ConnectionService.connections[callp]?.setRinging()
playRingBack() playRingBack()
return return
} }
"call progress" -> { "call progress" -> {
if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0) if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0)
stopMediaPlayer() stopMediaPlayer()
else else {
ConnectionService.connections[callp]?.setRinging()
playRingBack() playRingBack()
}
return return
} }
"incoming call" -> { "incoming call" -> {
@@ -910,85 +851,20 @@ class BaresipService: Service() {
"call incoming" -> { "call incoming" -> {
val peerUri = ev[1] val peerUri = ev[1]
Log.d(TAG, "Incoming call $uap/$callp/$peerUri") Log.d(TAG, "Incoming call $uap/$callp/$peerUri")
Call(callp, ua, peerUri, "in", "incoming").add()
if (speakerPhone && !Utils.isSpeakerPhoneOn(am)) if (Call.ofCallp(callp) == null)
Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(this), am) Call(callp, ua, peerUri, "in", "incoming").add()
if (ua.account.answerMode == Api.ANSWERMODE_AUTO) {
val newIntent = Intent(this, MainActivity::class.java) val extras = android.os.Bundle()
newIntent.flags = extras.putLong("uap", uap)
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or extras.putLong("callp", callp)
Intent.FLAG_ACTIVITY_NEW_TASK extras.putString("peerUri", peerUri)
newIntent.putExtra("action", "call answer") try {
newIntent.putExtra("callp", callp) tm.addNewIncomingCall(getPhoneAccountHandle(this), extras)
startActivity(newIntent) } catch (e: Exception) {
return Log.e(TAG, "Telecom addNewIncomingCall failed: ${e.message}")
}
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
} }
return
} }
"call answered" -> { "call answered" -> {
stopMediaPlayer() stopMediaPlayer()
@@ -1001,27 +877,31 @@ class BaresipService: Service() {
stopMediaPlayer() stopMediaPlayer()
} }
"call established" -> { "call established" -> {
ConnectionService.connections[callp]?.setActive()
nm.cancel(CALL_NOTIFICATION_ID) nm.cancel(CALL_NOTIFICATION_ID)
Log.d(TAG, "AoR $aor call $callp established in mode ${am.mode}") Log.d(TAG, "AoR $aor call $callp established")
if (am.mode != MODE_IN_COMMUNICATION)
am.mode = MODE_IN_COMMUNICATION
call!!.status.value = "connected" call!!.status.value = "connected"
call.onhold = false call.onhold = false
if (ua.account.callHistory) call.startTime = GregorianCalendar()
call.startTime = GregorianCalendar() updateStatusNotification()
if (!isMainVisible) if (!isMainVisible)
return return
} }
"call update" -> { "call update" -> {
call!!.held = when (ev[1].toInt()) { val held = when (ev[1].toInt()) {
Api.SDP_INACTIVE, Api.SDP_RECVONLY -> true 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 (call.state() == Api.CALL_STATE_EARLY) {
if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0) if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0)
stopMediaPlayer() stopMediaPlayer()
else else {
ConnectionService.connections[callp]?.setRinging()
playRingBack() playRingBack()
}
} }
if (!isMainVisible || call.status.value != "connected") if (!isMainVisible || call.status.value != "connected")
return return
@@ -1085,6 +965,11 @@ class BaresipService: Service() {
} }
"call closed" -> { "call closed" -> {
Log.d(TAG, "AoR $aor call $callp is closed prm: ${ev[1]}") 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) nm.cancel(CALL_NOTIFICATION_ID)
if (call != null) { if (call != null) {
stopRinging() stopRinging()
@@ -1107,16 +992,15 @@ class BaresipService: Service() {
call.onHoldCall = null call.onHoldCall = null
} }
call.remove() call.remove()
updateStatusNotification()
if (call.conferenceCall && ua.calls().isEmpty()) if (call.conferenceCall && ua.calls().isEmpty())
Api.module_unload("mixminus") Api.module_unload("mixminus")
val reason = ev[1] val reason = ev[1]
val tone = ev[2] val tone = ev[2]
if (tone == "busy") { if (tone == "busy")
playBusy() playBusy()
}
else if (!Call.inCall()) { else if (!Call.inCall()) {
resetCallVolume() resetCallVolume()
abandonAudioFocus(applicationContext)
proximitySensing(false) proximitySensing(false)
} }
if (call.dir == "out") 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") @Suppress("unused")
@Keep @Keep
fun started() { fun started() {
@@ -1414,7 +1332,7 @@ class BaresipService: Service() {
callActionUri = "" callActionUri = ""
Log.d(TAG, "Battery optimizations are ignored: " + Log.d(TAG, "Battery optimizations are ignored: " +
"${pm.isIgnoringBatteryOptimizations(packageName)}") "${pm.isIgnoringBatteryOptimizations(packageName)}")
Log.d(TAG, "Wifi lock is held: ${wifiLock.isHeld}") Log.d(TAG, "WiFi lock is held: ${wifiLock.isHeld}")
updateStatusNotification() updateStatusNotification()
} }
@@ -1460,7 +1378,6 @@ class BaresipService: Service() {
if (VERSION.SDK_INT >= 31) if (VERSION.SDK_INT >= 31)
snb.foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE snb.foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE
snb.setOngoing(true) snb.setOngoing(true)
snb.setCategory(Notification.CATEGORY_SERVICE)
val notification = snb.build() val notification = snb.build()
notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or
Notification.FLAG_ONGOING_EVENT Notification.FLAG_ONGOING_EVENT
@@ -1470,8 +1387,8 @@ class BaresipService: Service() {
@SuppressLint("UnspecifiedImmutableFlag") @SuppressLint("UnspecifiedImmutableFlag")
private fun showStatusNotification() { private fun showStatusNotification() {
val intent = Intent(applicationContext, MainActivity::class.java) val intent = Intent(applicationContext, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN) .setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER) .addCategory(Intent.CATEGORY_LAUNCHER)
val pi = PendingIntent.getActivity(applicationContext, STATUS_REQ_CODE, intent, val pi = PendingIntent.getActivity(applicationContext, STATUS_REQ_CODE, intent,
PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_IMMUTABLE)
val deleteIntent = Intent(this, BaresipService::class.java) val deleteIntent = Intent(this, BaresipService::class.java)
@@ -1484,26 +1401,18 @@ class BaresipService: Service() {
) )
val notificationLayout = RemoteViews(packageName, R.layout.status_notification) val notificationLayout = RemoteViews(packageName, R.layout.status_notification)
snb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) snb.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSmallIcon(R.drawable.ic_notification_b) .setSmallIcon(R.drawable.ic_notification_b)
.setContentIntent(pi) .setContentIntent(pi)
.setDeleteIntent(dpi) .setDeleteIntent(dpi)
.setOngoing(true) .setOngoing(true)
.setStyle(NotificationCompat.DecoratedCustomViewStyle()) .setCategory(Notification.CATEGORY_SERVICE)
.setCustomContentView(notificationLayout) .setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomContentView(notificationLayout)
val notification = buildStatusNotification() val notification = buildStatusNotification()
try { try {
if (VERSION.SDK_INT >= 29) if (VERSION.SDK_INT >= 34)
startForeground( startForeground(STATUS_NOTIFICATION_ID, notification,
STATUS_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
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
)
else else
startForeground(STATUS_NOTIFICATION_ID, notification) startForeground(STATUS_NOTIFICATION_ID, notification)
} catch (e: Exception) { } catch (e: Exception) {
@@ -1511,30 +1420,107 @@ class BaresipService: Service() {
} }
} }
private fun updateStatusNotification() { fun updateStatusNotification() {
val notificationLayout = RemoteViews(packageName, R.layout.status_notification) val activeCall = Call.calls().find {
for (i: Int in 0..3) { it.status.value == "connected" || it.status.value == "outgoing" || it.status.value == "answered"
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) val builder = NotificationCompat.Builder(this, LOW_CHANNEL_ID)
else val intent = Intent(applicationContext, MainActivity::class.java)
notificationLayout.setViewVisibility(R.id.etc, View.INVISIBLE) .setAction(Intent.ACTION_MAIN)
snb.setCustomContentView(notificationLayout) .addCategory(Intent.CATEGORY_LAUNCHER)
// Don't know why, but without the delay the notification is not always updated
Timer().schedule(250) { val pi = PendingIntent.getActivity(applicationContext, STATUS_REQ_CODE, intent, PendingIntent.FLAG_IMMUTABLE)
nm.notify(STATUS_NOTIFICATION_ID, buildStatusNotification()) 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)
} }
} }
@@ -1544,6 +1530,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() { private fun startRinging() {
am.mode = AudioManager.MODE_RINGTONE am.mode = AudioManager.MODE_RINGTONE
rt!!.isLooping = true rt!!.isLooping = true
@@ -1592,6 +1656,7 @@ class BaresipService: Service() {
// First, check if the ringer volume is actually non-zero // First, check if the ringer volume is actually non-zero
if (am.getStreamVolume(AudioManager.STREAM_RING) == 0) return false if (am.getStreamVolume(AudioManager.STREAM_RING) == 0) return false
// Finally, check the system "Vibrate for calls" setting. // 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 { return try {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
Settings.System.getInt(contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0 Settings.System.getInt(contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0
@@ -1666,7 +1731,6 @@ class BaresipService: Service() {
stopMediaPlayer() stopMediaPlayer()
if (!Call.inCall()) { if (!Call.inCall()) {
resetCallVolume() resetCallVolume()
abandonAudioFocus(applicationContext)
proximitySensing(false) proximitySensing(false)
} }
} }
@@ -1732,15 +1796,9 @@ class BaresipService: Service() {
} }
private fun updateNetwork() { 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() updateDnsServers()
val addresses = linkAddresses() val addresses = linkAddresses()
Log.d(TAG, "Old/new link addresses $linkAddresses/$addresses") Log.d(TAG, "Old/new link addresses $linkAddresses/$addresses")
var added = 0 var added = 0
@@ -1751,6 +1809,7 @@ class BaresipService: Service() {
else else
added++ added++
} }
var removed = 0 var removed = 0
for (a in linkAddresses) for (a in linkAddresses)
if (!addresses.containsKey(a.key)) { if (!addresses.containsKey(a.key)) {
@@ -1771,16 +1830,25 @@ class BaresipService: Service() {
Api.net_debug() Api.net_debug()
if (activeNetwork != null) { // Robust Wi-Fi Lock Logic:
val caps = cm.getNetworkCapabilities(activeNetwork) // Check ALL networks, not just the 'active' one, to ensure we keep the radio
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { // 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") Log.d(TAG, "Acquiring WiFi Lock")
wifiLock.acquire() 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<String, String> { private fun linkAddresses(): MutableMap<String, String> {
@@ -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() { private fun cleanService() {
if (!isServiceClean) { if (!isServiceClean) {
if (bluetoothReceiverRegistered) { try {
this.unregisterReceiver(bluetoothReceiver) if (hotSpotReceiverRegistered) {
bluetoothReceiverRegistered = false applicationContext.unregisterReceiver(hotSpotReceiver)
hotSpotReceiverRegistered = false
}
} catch (_: IllegalArgumentException) {
Log.e(TAG, "hotSpotReceiver was not registered with applicationContext")
} }
if (hotSpotReceiverRegistered) { val callps = ConnectionService.connections.keys.toList()
unregisterReceiver(hotSpotReceiver) for (callp in callps)
hotSpotReceiverRegistered = false ConnectionService.onCallClosed(callp)
ConnectionService.pendingOutgoingConnection?.let {
it.setDisconnected(DisconnectCause(DisconnectCause.CANCELED))
it.destroy()
ConnectionService.pendingOutgoingConnection = null
} }
stopRinging() stopRinging()
stopMediaPlayer() stopMediaPlayer()
abandonAudioFocus(applicationContext)
uas.value = emptyList() uas.value = emptyList()
uasStatus.value = emptyMap() uasStatus.value = emptyMap()
callHistory.clear() callHistory.clear()
@@ -1903,6 +1988,7 @@ class BaresipService: Service() {
@SuppressLint("MutableCollectionMutableState") @SuppressLint("MutableCollectionMutableState")
companion object { companion object {
var instance: BaresipService? = null
var isServiceRunning = false var isServiceRunning = false
var isStartReceived = false var isStartReceived = false
var isConfigInitialized = false var isConfigInitialized = false
@@ -1964,10 +2050,15 @@ class BaresipService: Service() {
private var agc: AutomaticGainControl? = null private var agc: AutomaticGainControl? = null
private val nsAvailable = NoiseSuppressor.isAvailable() private val nsAvailable = NoiseSuppressor.isAvailable()
private var ns: NoiseSuppressor? = null private var ns: NoiseSuppressor? = null
private var btAdapter: BluetoothAdapter? = null
private var recorderSessionId = 0 private var recorderSessionId = 0
internal const val KEY_TEXT_REPLY = "key_text_reply_baresip" 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) { fun postServiceEvent(event: ServiceEvent) {
serviceEvents.add(event) serviceEvents.add(event)
@@ -1996,8 +2087,6 @@ class BaresipService: Service() {
.build() .build()
if (AudioManagerCompat.requestAudioFocus(am, audioFocusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { if (AudioManagerCompat.requestAudioFocus(am, audioFocusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d(TAG, "requestAudioFocus granted") Log.d(TAG, "requestAudioFocus granted")
if (isBluetoothHeadsetConnected(ctx))
startBluetoothSco(ctx, 250L, 3)
} else { } else {
Log.w(TAG, "requestAudioFocus denied") Log.w(TAG, "requestAudioFocus denied")
audioFocusRequest = null audioFocusRequest = null
@@ -2005,78 +2094,5 @@ class BaresipService: Service() {
return 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)
}
} }
} }
@@ -177,9 +177,6 @@ private var password = mutableStateOf("")
private val selectItems = mutableStateOf(listOf<String>()) private val selectItems = mutableStateOf(listOf<String>())
private val selectItemAction = mutableStateOf<(Int) -> Unit>({ _ -> run {} }) private val selectItemAction = mutableStateOf<(Int) -> Unit>({ _ -> run {} })
private val showSelectItemDialog = mutableStateOf(false) 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( fun NavGraphBuilder.mainScreenRoute(
navController: NavController, navController: NavController,
@@ -536,7 +533,7 @@ private fun TopAppBar(
val recOffImage = Icons.Filled.VoiceOverOff val recOffImage = Icons.Filled.VoiceOverOff
val recOnImage = Icons.Filled.RecordVoiceOver val recOnImage = Icons.Filled.RecordVoiceOver
var isRecOn by remember { mutableStateOf(BaresipService.isRecOn) } 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) } var menuExpanded by remember { mutableStateOf(false) }
val about = stringResource(R.string.about) val about = stringResource(R.string.about)
@@ -655,11 +652,22 @@ private fun TopAppBar(
.clip(CircleShape) .clip(CircleShape)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
if (Build.VERSION.SDK_INT >= 31) val isCurrentlyOn = isSpeakerOn
Log.d(TAG, "Toggling speakerphone when dev/mode is " + val aor = viewModel.selectedAor.value
"${am.communicationDevice!!.type}/${am.mode}") val ua = uas.value.find { it.account.aor == aor }
isSpeakerOn.value = !Utils.isSpeakerPhoneOn(am) val call = ua?.currentCall()
Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(ctx), am) 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 = { onLongClick = {
alertTitle.value = speakerPhoneTitle alertTitle.value = speakerPhoneTitle
@@ -671,7 +679,7 @@ private fun TopAppBar(
Icon( Icon(
imageVector = Icons.Filled.SpeakerPhone, imageVector = Icons.Filled.SpeakerPhone,
contentDescription = null, contentDescription = null,
tint = if (isSpeakerOn.value) tint = if (isSpeakerOn)
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
else else
MaterialTheme.colorScheme.onPrimary, MaterialTheme.colorScheme.onPrimary,
@@ -891,8 +899,7 @@ private fun MainContent(navController: NavController, viewModel: ViewModel, cont
val calls by viewModel.calls.collectAsState() val calls by viewModel.calls.collectAsState()
val selectedAor by viewModel.selectedAor.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 dialingOrRinging = filteredCalls.any { it.status.value == "outgoing" || it.status.value == "incoming" }
val conferenceCall = filteredCalls.any { it.conferenceCall } val conferenceCall = filteredCalls.any { it.conferenceCall }
@@ -1490,12 +1497,13 @@ private fun CallRow(
onClick = { onClick = {
if (call.terminated.value) return@IconButton if (call.terminated.value) return@IconButton
call.terminated.value = true call.terminated.value = true
abandonAudioFocus(ctx) val connection = ConnectionService.connections[call.callp]
Log.d( if (connection != null)
TAG, connection.onDisconnect()
"AoR ${call.ua.account.aor} canceling call ${call.callp} with ${call.callUri.value}" else {
) Log.d(TAG, "AoR ${call.ua.account.aor} canceling call ${call.callp}")
Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated") Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated")
}
}, },
) { ) {
Icon( Icon(
@@ -1516,9 +1524,13 @@ private fun CallRow(
onClick = { onClick = {
if (call.terminated.value) return@IconButton if (call.terminated.value) return@IconButton
call.terminated.value = true call.terminated.value = true
abandonAudioFocus(ctx) val connection = ConnectionService.connections[call.callp]
Log.d(TAG, "AoR ${call.ua.account.aor} hanging up call ${call.callp} with ${call.callUri.value}") if (connection != null)
Api.ua_hangup(call.ua.uap, call.callp, 487, "Request Terminated") 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( Icon(
@@ -1532,19 +1544,26 @@ private fun CallRow(
IconButton( IconButton(
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
onClick = { onClick = {
val connection = ConnectionService.connections[call.callp]
if (call.onhold) { if (call.onhold) {
Log.d( Log.d(
TAG, TAG,
"AoR ${call.ua.account.aor} resuming call ${call.callp} with ${call.callUri.value}" "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 call.onhold = false
} else { } else {
Log.d( Log.d(
TAG, TAG,
"AoR ${call.ua.account.aor} holding call ${call.callp} with ${call.callUri.value}" "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 call.onhold = true
} }
}, },
@@ -1568,12 +1587,15 @@ private fun CallRow(
enabled = call.transferButtonEnabled.value, enabled = call.transferButtonEnabled.value,
onClick = { onClick = {
if (call.onHoldCall != null) { if (call.onHoldCall != null) {
val connection = ConnectionService.connections[call.callp]
connection?.onHold()
if (!call.executeTransfer()) { if (!call.executeTransfer()) {
alertTitle.value = ctx.getString(R.string.notice) alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.transfer_failed) alertMessage.value = ctx.getString(R.string.transfer_failed)
showAlert.value = true showAlert.value = true
} }
} else }
else
showTransferDialog = true showTransferDialog = true
}, },
) { ) {
@@ -1970,16 +1992,18 @@ private fun CallRow(
@Composable @Composable
private fun OnHoldNotice() { private fun OnHoldNotice() {
OutlinedButton( Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
onClick = {}, OutlinedButton(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), onClick = {},
modifier = Modifier.padding(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
shape = RoundedCornerShape(20) modifier = Modifier.padding(16.dp),
) { shape = RoundedCornerShape(20)
Text( ) {
text = stringResource(R.string.call_is_on_hold), Text(
fontSize = 18.sp 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) { private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String, conferenceCall: Boolean,
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager onHoldCallp: Long = 0L) {
val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! val aor = viewModel.selectedAor.value
val aor = ua.account.aor val ua = UserAgent.ofAor(aor)!!
val peerUri = if (Utils.isTelNumber(uriText)) val peerUri = if (Utils.isTelNumber(uriText))
"tel:$uriText" "tel:$uriText"
else 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) alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri)
showAlert.value = true 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 }) else if (Call.calls().any { it.ua.account.aor != ua.account.aor })
Toast.makeText(ctx, R.string.call_already_active, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.call_already_active, Toast.LENGTH_SHORT).show()
else { else {
viewModel.dialerState.callButtonsEnabled.value = false val tm = ctx.getSystemService(Context.TELECOM_SERVICE) as android.telecom.TelecomManager
if (Build.VERSION.SDK_INT < 31) { val extras = android.os.Bundle()
Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") extras.putParcelable(android.telecom.TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE,
am.mode = AudioManager.MODE_IN_COMMUNICATION BaresipService.getPhoneAccountHandle(ctx))
runCall(ctx, viewModel, ua, uri, conferenceCall) val callExtras = android.os.Bundle()
} else { callExtras.putBoolean("conferenceCall", conferenceCall)
if (am.mode == AudioManager.MODE_IN_COMMUNICATION) { callExtras.putLong("uap", ua.uap)
runCall(ctx, viewModel, ua, uri, conferenceCall) if (onHoldCallp != 0L)
} else { callExtras.putLong("onHoldCallp", onHoldCallp)
audioModeChangedListener = AudioManager.OnModeChangedListener { mode -> extras.putBundle(android.telecom.TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, callExtras)
if (mode == AudioManager.MODE_IN_COMMUNICATION) { try {
Log.d(TAG, "Audio mode changed to MODE_IN_COMMUNICATION using " + Log.d(TAG, "Placing Telecom call to $uri with uap=${ua.uap}")
"device ${am.communicationDevice!!.type}") tm.placeCall(uri.toUri(), extras)
if (audioModeChangedListener != null) { } catch (e: SecurityException) {
am.removeOnModeChangedListener(audioModeChangedListener!!) Log.e(TAG, "placeCall failed: ${e.message}")
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
}
} }
} }
} }
private fun answer(ctx: Context, call: Call) { private fun answer(ctx: Context, call: Call) {
Log.d(TAG, "AoR ${call.ua.account.aor} answering call from ${call.callUri.value}") 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) val intent = Intent(ctx, BaresipService::class.java)
intent.action = "Call Answer" intent.action = "Call Answer"
intent.putExtra("uap", call.ua.uap) intent.putExtra("uap", call.ua.uap)
@@ -2091,59 +2102,12 @@ private fun answer(ctx: Context, call: Call) {
private fun reject(call: Call) { private fun reject(call: Call) {
Log.d(TAG, "AoR ${call.ua.account.aor} rejecting call ${call.callp} from ${call.callUri.value}") Log.d(TAG, "AoR ${call.ua.account.aor} rejecting call ${call.callp} from ${call.callUri.value}")
call.rejected = true val connection = ConnectionService.connections[call.callp]
Api.ua_hangup(call.ua.uap, call.callp, 486, "Busy Here") if (connection != null)
} connection.onReject()
else {
private fun runCall(ctx: Context, viewModel: ViewModel, ua: UserAgent, uri: String, conferenceCall: Boolean) { call.rejected = true
callRunnable = Runnable { Api.ua_hangup(call.ua.uap, call.callp, 486, "Busy Here")
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
} }
} }
@@ -2161,12 +2125,22 @@ private fun transfer(ctx: Context, viewModel: ViewModel, ua: UserAgent, uriText:
val call = ua.currentCall() val call = ua.currentCall()
if (call != null) { if (call != null) {
if (attended) { 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.referTo = uri
call(ctx, viewModel, ua, uri, false,call) makeCall(ctx, viewModel, uri, false, call.callp)
} }
} }
else { else {
val connection = ConnectionService.connections[call.callp]
connection?.onHold()
if (!call.transfer(uri)) { if (!call.transfer(uri)) {
alertTitle.value = ctx.getString(R.string.notice) alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.transfer_failed) 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 call.focusDtmf.value = true
viewModel.requestShowKeyboard() viewModel.requestShowKeyboard()
} }
Log.d(TAG, "Showing call ${call.callp} from ${call.ua.account.aor} with status ${call.status.value}")
when (call.status.value) { when (call.status.value) {
"outgoing", "transferring", "answered" -> { "outgoing", "transferring", "answered", "disconnecting" -> {
call.callUriLabel.value = if (call.status.value == "answered") call.callUriLabel.value = if (call.status.value == "answered")
ctx.getString(R.string.incoming_call_from_dots) ctx.getString(R.string.incoming_call_from_dots)
else else
@@ -2381,6 +2356,9 @@ fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params
"call update" -> { "call update" -> {
showCall(ctx, viewModel, ua) showCall(ctx, viewModel, ua)
} }
"update calls" -> {
viewModel.updateCalls(Call.calls().toList())
}
"call verify" -> { "call verify" -> {
val callp = params[1] as Long val callp = params[1] as Long
val call = Call.ofCallp(callp) val call = Call.ofCallp(callp)
@@ -2457,13 +2435,14 @@ fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params
val call = Call.ofCallp(callp) val call = Call.ofCallp(callp)
if (call in Call.calls()) if (call in Call.calls())
Api.ua_hangup(uap, callp, 487, "Request Terminated") 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) showCall(ctx, viewModel, ua)
} }
"transfer failed" -> { "transfer failed" -> {
showCall(ctx, viewModel, ua) showCall(ctx, viewModel, ua)
} }
"call closed" -> { "call closed" -> {
viewModel.updateCalls(Call.calls().toList())
val activity = ctx as? Activity val activity = ctx as? Activity
if (activity != null) { if (activity != null) {
val kgm = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager 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) if (aor == viewModel.selectedAor.value)
viewModel.triggerAccountUpdate() 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]}'") else -> Log.e(TAG, "Unknown event '${ev[0]}'")
} }
@@ -2777,19 +2766,3 @@ private fun restore(ctx: Context, password: String, onRestartApp: () -> Unit) {
downloadsOutputUri = null 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)
}
}
}
@@ -63,6 +63,9 @@ class ViewModel: ViewModel() {
private val _micIcon = MutableStateFlow(Icons.Filled.Mic) private val _micIcon = MutableStateFlow(Icons.Filled.Mic)
val micIcon = _micIcon.asStateFlow() val micIcon = _micIcon.asStateFlow()
private val _isSpeakerOn = MutableStateFlow(false)
val isSpeakerOn = _isSpeakerOn.asStateFlow()
private val _isDialpadVisible = MutableStateFlow(false) private val _isDialpadVisible = MutableStateFlow(false)
val isDialpadVisible = _isDialpadVisible.asStateFlow() val isDialpadVisible = _isDialpadVisible.asStateFlow()
@@ -109,6 +112,10 @@ class ViewModel: ViewModel() {
_micIcon.value = icon _micIcon.value = icon
} }
fun updateSpeakerPhoneStatus(on: Boolean) {
_isSpeakerOn.value = on
}
fun toggleDialpadVisibility() { fun toggleDialpadVisibility() {
_isDialpadVisible.value = !_isDialpadVisible.value _isDialpadVisible.value = !_isDialpadVisible.value
} }
+1
View File
@@ -505,6 +505,7 @@
<string name="allow_video_send">Accept sending of video to \'%1$s\'\?</string> <string name="allow_video_send">Accept sending of video to \'%1$s\'\?</string>
<string name="allow_video_recv">Accept receiving video from \'%1$s\'\?</string> <string name="allow_video_recv">Accept receiving video from \'%1$s\'\?</string>
<string name="call_is_on_hold">Call is on hold</string> <string name="call_is_on_hold">Call is on hold</string>
<string name="call_is_connected">Call is connected</string>
<string name="rec_in_call">Recording can be turned on or off only when call is not <string name="rec_in_call">Recording can be turned on or off only when call is not
connected</string> connected</string>
<string name="call_transfer">Call Transfer</string> <string name="call_transfer">Call Transfer</string>