@file:Suppress("DEPRECATION") package com.tutpro.baresip import android.Manifest.permission.RECORD_AUDIO import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothHeadset import android.bluetooth.BluetoothManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.content.res.Configuration import android.database.ContentObserver import android.graphics.ImageDecoder import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioFocusRequest import android.media.AudioManager import android.media.AudioManager.MODE_IN_COMMUNICATION import android.media.AudioManager.MODE_IN_CALL import android.media.AudioManager.MODE_NORMAL import android.media.MediaPlayer import android.media.Ringtone import android.media.RingtoneManager import android.media.audiofx.AcousticEchoCanceler import android.media.audiofx.AutomaticGainControl import android.media.audiofx.NoiseSuppressor import android.net.ConnectivityManager import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.Uri import android.net.wifi.WifiManager import android.os.Build import android.os.Build.VERSION import android.os.CountDownTimer import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.provider.ContactsContract import android.provider.Settings import android.system.OsConstants import android.telecom.Connection import android.telecom.DisconnectCause import android.telecom.PhoneAccountHandle import android.telecom.TelecomManager import android.telephony.PhoneStateListener import android.telephony.ServiceState import android.telephony.TelephonyCallback import android.telephony.TelephonyManager import android.view.View import android.widget.RemoteViews import android.widget.Toast import androidx.annotation.Keep import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.MessagingStyle import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import com.tutpro.baresip.Utils.e164Uri import com.tutpro.baresip.Utils.toCircle import java.io.File import java.net.InetAddress import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.util.GregorianCalendar import java.util.Timer import java.util.TimerTask import kotlin.concurrent.schedule import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch class BaresipService: Service() { internal lateinit var intent: Intent private lateinit var am: AudioManager private lateinit var nt: Ringtone private lateinit var nm: NotificationManager private lateinit var snb: NotificationCompat.Builder private lateinit var cm: ConnectivityManager private lateinit var pm: PowerManager private lateinit var wm: WifiManager private lateinit var tm: TelecomManager private lateinit var btm: BluetoothManager private lateinit var telephonyManager: TelephonyManager private lateinit var telephonyCallback: TelephonyCallback @Suppress("DEPRECATION") private lateinit var phoneStateListener: PhoneStateListener private lateinit var vibrator: Vibrator private lateinit var partialWakeLock: PowerManager.WakeLock private lateinit var proximityWakeLock: PowerManager.WakeLock private lateinit var wifiLock: WifiManager.WifiLock private lateinit var bluetoothReceiver: BroadcastReceiver private lateinit var hotSpotReceiver: BroadcastReceiver private lateinit var airplaneModeReceiver: BroadcastReceiver private lateinit var simStateReceiver: BroadcastReceiver private lateinit var androidContactsObserver: ContentObserver private lateinit var networkCallback: ConnectivityManager.NetworkCallback private lateinit var stopState: String private lateinit var quitTimer: CountDownTimer private data class PendingMessage(val sender: String, val body: String, val time: Long) private val pendingMessages = mutableListOf() private var vbTimer: Timer? = null private var origVolume = mutableMapOf() private var linkAddresses = mutableMapOf() private var activeNetwork: Network? = null private var allNetworks = mutableSetOf() private var lastLinkProperties = mutableMapOf() private var hotSpotIsEnabled = false private var hotSpotAddresses = mapOf() private var mediaPlayer: MediaPlayer? = null private var androidContactsObserverRegistered = false private var hotSpotReceiverRegistered = false private var bluetoothReceiverRegistered = false private var airplaneModeReceiverRegistered = false private var simStateReceiverRegistered = false private var telephonyCallbackRegistered = false private var isNotificationInCall = false private var isServiceClean = false private var cleanupRunnable: Runnable? = null private var previousMobileServiceState = -1 @SuppressLint("WakelockTimeout") override fun onCreate() { super.onCreate() Log.i(TAG, "BaresipService onCreate") instance = this intent = Intent("com.tutpro.baresip.EVENT") intent.setPackage("com.tutpro.baresip") filesPath = filesDir.absolutePath pName = packageName am = getSystemService(AUDIO_SERVICE) as AudioManager val ntUri = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_NOTIFICATION) nt = RingtoneManager.getRingtone(this, ntUri) val rtUri = if (Preferences(this).ringtoneUri == "") Settings.System.DEFAULT_RINGTONE_URI else Preferences(this).ringtoneUri!!.toUri() rt = RingtoneManager.getRingtone(this, rtUri) nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager createNotificationChannels() snb = NotificationCompat.Builder(this, LOW_CHANNEL_ID) pm = getSystemService(POWER_SERVICE) as PowerManager vibrator = if (VERSION.SDK_INT >= 31) { val vibratorManager = getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator } else { @Suppress("DEPRECATION") getSystemService(VIBRATOR_SERVICE) as Vibrator } // This is needed to keep service running also in Doze Mode partialWakeLock = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, "com.tutpro.baresip:wakelock" ).apply { setReferenceCounted(false) } networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) Log.d(TAG, "Network $network is available") synchronized(allNetworks) { if (network !in allNetworks) allNetworks.add(network) } } override fun onLosing(network: Network, maxMsToLive: Int) { super.onLosing(network, maxMsToLive) Log.d(TAG, "Network $network is losing after $maxMsToLive ms") } override fun onLost(network: Network) { super.onLost(network) Log.d(TAG, "Network $network is lost") synchronized(allNetworks) { if (network in allNetworks) allNetworks.remove(network) } if (isServiceRunning) updateNetwork() } override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { super.onCapabilitiesChanged(network, caps) synchronized(allNetworks) { if (network !in allNetworks) allNetworks.add(network) } if (isServiceRunning) updateNetwork() } override fun onLinkPropertiesChanged(network: Network, props: LinkProperties) { super.onLinkPropertiesChanged(network, props) if (Utils.linkPropertiesEqual(props, lastLinkProperties[network])) return lastLinkProperties[network] = props Log.d(TAG, "Network $network link properties changed: $props") synchronized(allNetworks) { if (network !in allNetworks) allNetworks.add(network) } if (isServiceRunning) updateNetwork() } } cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager cm.registerNetworkCallback( NetworkRequest.Builder() .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .build(), networkCallback ) wm = getSystemService(WIFI_SERVICE) as WifiManager hotSpotIsEnabled = Utils.isHotSpotOn(wm) hotSpotReceiver = object : BroadcastReceiver() { override fun onReceive(contxt: Context, intent: Intent) { val action = intent.action if ("android.net.wifi.WIFI_AP_STATE_CHANGED" == action) { val state = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0) if (WifiManager.WIFI_STATE_ENABLED == state % 10) { if (hotSpotIsEnabled) Log.d(TAG, "HotSpot is still enabled") else { Log.d(TAG, "HotSpot is enabled") hotSpotIsEnabled = true Timer().schedule(1000) { hotSpotAddresses = Utils.hotSpotAddresses() Log.d(TAG, "HotSpot addresses $hotSpotAddresses") if (hotSpotAddresses.isNotEmpty()) { var reset = false for ((k, v) in hotSpotAddresses) if (afMatch(k)) if (Api.net_add_address_ifname(k, v) != 0) Log.e(TAG, "Failed to add $v address $k") else reset = true if (reset) Timer().schedule(2000) { updateNetwork() } } else Log.w(TAG, "Could not get hotspot addresses") } } } else { if (!hotSpotIsEnabled) Log.d(TAG, "HotSpot is still disabled") else { Log.d(TAG, "HotSpot is disabled") hotSpotIsEnabled = false if (hotSpotAddresses.isNotEmpty()) { for ((k, _) in hotSpotAddresses) if (Api.net_rm_address(k) != 0) Log.e(TAG, "Failed to remove address $k") hotSpotAddresses = mapOf() updateNetwork() } } } } } } ContextCompat.registerReceiver( this, hotSpotReceiver, IntentFilter("android.net.wifi.WIFI_AP_STATE_CHANGED"), ContextCompat.RECEIVER_NOT_EXPORTED ) hotSpotReceiverRegistered = true airplaneModeReceiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) { val isAirplaneModeOn = intent.getBooleanExtra("state", false) Log.d(TAG, "Airplane mode changed: $isAirplaneModeOn") if (VERSION.SDK_INT >= 29) updateMobileStatus() } } } ContextCompat.registerReceiver( this, airplaneModeReceiver, IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED), ContextCompat.RECEIVER_EXPORTED ) airplaneModeReceiverRegistered = true simStateReceiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { if (intent.action == "android.intent.action.SIM_STATE_CHANGED") { Log.d(TAG, "SIM state changed") if (VERSION.SDK_INT >= 29) updateMobileStatus() } } } ContextCompat.registerReceiver( this, simStateReceiver, IntentFilter("android.intent.action.SIM_STATE_CHANGED"), ContextCompat.RECEIVER_EXPORTED ) simStateReceiverRegistered = true tm = getSystemService(TELECOM_SERVICE) as TelecomManager btm = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager btAdapter = btm.adapter 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(this@BaresipService, 1000L, 3) } BluetoothHeadset.STATE_DISCONNECTED -> { Log.d(TAG, "Bluetooth headset is disconnected") if (audioFocusRequest != null) stopBluetoothSco(this@BaresipService) } } } 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") } } } } } val bluetoothFilter = IntentFilter() bluetoothFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED) bluetoothFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) bluetoothFilter.addAction(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) ContextCompat.registerReceiver( this, bluetoothReceiver, bluetoothFilter, ContextCompat.RECEIVER_EXPORTED ) bluetoothReceiverRegistered = true telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager if (VERSION.SDK_INT >= 31) telephonyCallback = object : TelephonyCallback(), TelephonyCallback.ServiceStateListener { override fun onServiceStateChanged(serviceState: ServiceState) { updateMobileStatusFromServiceState(serviceState.state) } } else { @Suppress("DEPRECATION") phoneStateListener = object : PhoneStateListener() { @Deprecated("Deprecated in Java") override fun onServiceStateChanged(serviceState: ServiceState) { updateMobileStatusFromServiceState(serviceState.state) } } } proximityWakeLock = pm.newWakeLock( PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "com.tutpro.baresip:proximity_wakelock" ) wifiLock = if (VERSION.SDK_INT < 29) { @Suppress("DEPRECATION") wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "Baresip") } else wm.createWifiLock(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, "Baresip") wifiLock.setReferenceCounted(false) androidContactsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(self: Boolean) { Log.d(TAG, "Android contacts change") if (contactsMode != "baresip") { Contact.loadAndroidContacts(this@BaresipService) Contact.contactsUpdate() } } } stopState = "initial" quitTimer = object : CountDownTimer(5000, 1000) { override fun onTick(millisUntilFinished: Long) { Log.d(TAG, "Seconds remaining: ${millisUntilFinished / 1000}") } override fun onFinish() { when (stopState) { "initial" -> { if (isServiceRunning) baresipStop(true) stopState = "force" quitTimer.start() } "force" -> { cleanService() isServiceRunning = false postServiceEvent( ServiceEvent("stopped", arrayListOf(""), System.nanoTime()) ) stopSelf() // exitProcess(0) } } } } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val action: String if (intent == null) { action = "Start" Log.d(TAG, "Received onStartCommand with null intent") } else { // Utils.dumpIntent(intent) action = intent.action!! Log.d(TAG, "Received onStartCommand action $action") } when (action) { "Start" -> { val sender = intent?.getStringExtra("sender") val body = intent?.getStringExtra("body") val time = intent?.getLongExtra("time", 0L) ?: 0L if (sender != null && body != null && time != 0L) { Handler(Looper.getMainLooper()).post { if (isNativeReady) { val mobileUa = uas.value.find { it.account.isMobile } if (mobileUa != null) handleIncomingMessage(mobileUa.uap, sender, body, time) else synchronized(pendingMessages) { pendingMessages.add(PendingMessage(sender, body, time)) } } else synchronized(pendingMessages) { pendingMessages.add(PendingMessage(sender, body, time)) } } } if (isStartReceived || isServiceRunning) return START_STICKY showStatusNotification() isStartReceived = true if (VERSION.SDK_INT < 31) { @Suppress("DEPRECATION") allNetworks = cm.allNetworks.toMutableSet() } updateDnsServers() updatePartialWakeLock() val assets = arrayOf("accounts", "config", "contacts") var file = File(filesPath) if (!file.exists()) { Log.i(TAG, "Creating baresip directory") try { File(filesPath).mkdirs() } catch (e: Error) { Log.e(TAG, "Failed to create directory: ${e.message}") } } for (a in assets) { file = File("${filesPath}/$a") if (!file.exists() && a != "config") { Log.i(TAG, "Copying asset '$a'") Utils.copyAssetToFile(this, a, "$filesPath/$a") } else Log.i(TAG, "Asset '$a' already copied") if (a == "config") Config.initialize(this) } Thread { if (contactsMode != "android") Contact.restoreBaresipContacts() if (contactsMode != "baresip") { Contact.loadAndroidContacts(this) registerAndroidContactsObserver() } Contact.contactsUpdate() CallHistoryNew.restore() Blocked.restore() BlockRule.restore() val recordings = File(filesDir, "recordings") val restored = File(filesPath, "restored") if (restored.exists()) { Log.d(TAG, "Clearing recordings") CallHistoryNew.clearRecordings() CallHistoryNew.save() if (recordings.exists()) recordings.deleteRecursively() restored.delete() } File(filesDir, "recordings").mkdir() File(filesDir, "tmp").mkdir() Message.restore() }.start() hotSpotAddresses = Utils.hotSpotAddresses() linkAddresses = linkAddresses() var addresses = "" for (la in linkAddresses) addresses = "$addresses;${la.key};${la.value}" Log.i(TAG, "Link addresses: $addresses") activeNetwork = cm.activeNetwork Log.i(TAG, "Active network: $activeNetwork") registerPhoneAccount() Log.i(TAG, "AEC/AGC/NS available = $aecAvailable/$agcAvailable/$nsAvailable") val userAgent = Config.variable("user_agent") val software = if (userAgent != "") userAgent else "baresip v${BuildConfig.VERSION_NAME} " + "(Android ${VERSION.RELEASE}/${System.getProperty("os.arch") ?: "?"})" Thread { baresipStart( filesPath, addresses.removePrefix(";"), logLevel, software ) }.start() isServiceRunning = true if (linkAddresses.isEmpty()) toast(getString(R.string.no_network), Toast.LENGTH_LONG) if (!aecAvailable) toast(getString(R.string.no_aec), Toast.LENGTH_LONG) if (VERSION.SDK_INT >= 29) { val baresipService = Intent(this, BaresipService::class.java) baresipService.action = "Check Roles" startService(baresipService) } } "Notification Dismissed" -> updateStatusNotification() "Start Content Observer" -> registerAndroidContactsObserver() "Stop Content Observer" -> unRegisterAndroidContactsObserver() "Call Answer" -> { val callp = intent!!.getLongExtra("callp", 0L) val call = Call.ofCallp(callp) stopRinging() stopMediaPlayer() setCallVolume() proximitySensing(proximitySensing) call?.answer() updateStatusNotification() } "Call Reject" -> { val callp = intent!!.getLongExtra("callp", 0L) val call = Call.ofCallp(callp) nm.cancel(CALL_NOTIFICATION_ID) stopRinging() stopMediaPlayer() if (call == null) Log.w(TAG, "onStartCommand did not find call $callp") else { val peerUri = call.peerUri val aor = call.ua.account.aor Log.d(TAG, "Aor $aor rejected incoming call $callp from $peerUri") call.reject() } } "Call Hangup" -> { val callp = intent!!.getLongExtra("callp", 0L) Log.d(TAG, "onStartCommand Hangup action for $callp") val call = Call.ofCallp(callp) call?.hangup(0, "") } "Transfer Deny" -> { val callp = intent!!.getLongExtra("callp", 0L) val call = Call.ofCallp(callp) if (call == null) Log.w(TAG, "onStartCommand did not find call $callp") else call.notifySipfrag(603, "Decline") nm.cancel(TRANSFER_NOTIFICATION_ID) } "Message Save" -> { val uap = intent!!.getLongExtra("uap", 0L) val ua = UserAgent.ofUap(uap) if (ua == null) Log.w(TAG, "onStartCommand did not find UA $uap") else { Message.updateAorMessage( ua.account.aor, intent.getStringExtra("time")!!.toLong() ) ua.account.unreadMessages = Message.unreadMessages(ua.account.aor) } nm.cancel(MESSAGE_NOTIFICATION_ID) } "Message Delete" -> { val uap = intent!!.getLongExtra("uap", 0L) val ua = UserAgent.ofUap(uap) if (ua == null) Log.w(TAG, "onStartCommand did not find UA $uap") else { Message.deleteAorMessage( ua.account.aor, intent.getStringExtra("time")!!.toLong() ) ua.account.unreadMessages = Message.unreadMessages(ua.account.aor) } nm.cancel(MESSAGE_NOTIFICATION_ID) } "Message Inline Reply" -> { val remoteInputResults = RemoteInput.getResultsFromIntent(intent!!) if (remoteInputResults != null) { val replyText = remoteInputResults.getCharSequence(KEY_TEXT_REPLY)?.toString() if (!replyText.isNullOrEmpty()) { val uap = intent.getLongExtra("uap", -1L) val ua = UserAgent.ofUap(uap)!! val aor = ua.account.aor var peerUri = intent.getStringExtra("peer")!! val timeStamp = intent.getLongExtra("time", 0L) if (Utils.isTelUri(peerUri)) { if (ua.account.telProvider == "") { Log.w(TAG, "No telephony provider for $aor") peerUri = "" } else peerUri = Utils.telToSip(peerUri, ua.account) } if (peerUri != "") { Log.d(TAG, "Direct Reply from $aor to $peerUri: $replyText") Message.updateAorMessage(aor, timeStamp) val time = System.currentTimeMillis() val msg = Message( aor, peerUri, replyText, time, MESSAGE_UP_WAIT, 0, "", false ) msg.add() if (Api.message_send(uap, peerUri, replyText, time.toString()) != 0) { Log.w(TAG, "message_send failed") msg.direction = MESSAGE_UP_FAIL msg.responseReason = getString(R.string.message_failed) } else ua.account.unreadMessages = Message.unreadMessages(aor) } } } nm.cancel(MESSAGE_NOTIFICATION_ID) } "Update Notification" -> updateStatusNotification() "Check Roles" -> { if (isNativeReady) addMobileUserAgent() } "Start Call" -> { val uap = intent!!.getLongExtra("uap", 0L) val uri = intent.getStringExtra("uri")!! val conferenceCall = intent.getBooleanExtra("conferenceCall", false) val onHoldCallp = intent.getLongExtra("onHoldCallp", 0L) if (!requestAudioFocus(this)) { toast(getString(R.string.audio_focus_denied)) return START_STICKY } runCall(uap, uri, conferenceCall, onHoldCallp) } "Stop" -> { cleanService() if (isServiceRunning) { baresipStop(false) quitTimer.start() } } else -> Log.e(TAG, "Unknown start action $action") } return START_STICKY } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val currentNightMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK when (currentNightMode) { Configuration.UI_MODE_NIGHT_NO -> darkTheme.value = Preferences(this).displayTheme == AppCompatDelegate.MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES -> darkTheme.value = true } } override fun onBind(intent: Intent): IBinder? { return null } override fun onDestroy() { super.onDestroy() Log.d(TAG, "onDestroy at Baresip Service") cleanService() instance = null if (isServiceRunning) sendBroadcast(Intent("com.tutpro.baresip.Restart")) } @Suppress("unused") @SuppressLint("UnspecifiedImmutableFlag", "DiscouragedApi", "FullScreenIntentPolicy") @Keep fun uaEvent(event: String, uap: Long, callp: Long) { if (!isServiceRunning) return val ev = event.split(",") if (ev[0] == "create") { val ua = UserAgent(uap) if (VERSION.SDK_INT < 29 && ua.account.isMobile) { Log.d(TAG, "Removing Mobile account on API < 29") CallHistoryNew.clear(ua.account.aor) Message.clearMessagesOfAor(ua.account.aor) Api.ua_destroy(uap) Account.saveAccounts() return } ua.status = if (ua.account.isMobile) { val isAirplaneModeOn = Utils.isAirplaneModeOn(this) val isSimReady = isSimReady() if (VERSION.SDK_INT >= 29) { if (Utils.pstnAccountHandle(this) == null || !isSimReady || isAirplaneModeOn) R.drawable.circle_white else circleRed.getValue(colorblind) } else { R.drawable.circle_white } } else if (ua.account.regint == 0) R.drawable.circle_white else circleYellow.getValue(colorblind) ua.add() val acc = ua.account if (acc.authUser != "" && acc.authPass == NO_AUTH_PASS) { val password = aorPasswords[acc.aor] if (password != null) { Api.account_set_auth_pass(acc.accp, password) acc.authPass = password } else Api.account_set_auth_pass(acc.accp, NO_AUTH_PASS) } Log.d(TAG, "got uaEvent $event/${acc.aor}") return } if (ev[0] == "sndfile dump") { Log.d(TAG, "Got sndfile dump ${ev[1]}") if (Call.inCall()) { if (ev[1].endsWith("enc.wav")) Call.calls()[0].dumpfiles[0] = ev[1] else Call.calls()[0].dumpfiles[1] = ev[1] } return } if (ev[0] == "recorder sessionid") { val sessionId = ev[1].toInt() if (sessionId > 0 && sessionId != recorderSessionId) { Log.d(TAG, "got new recorder sessionid $sessionId (was $recorderSessionId)") recorderSessionId = sessionId aec?.release() aec = null agc?.release() agc = null ns?.release() ns = null if (aecAvailable) try { aec = AcousticEchoCanceler.create(sessionId) if (aec != null) { aec!!.enabled = true Log.d(TAG, "AEC is ${if (aec!!.enabled) "enabled" else "not enabled"}") } } catch (e: Exception) { Log.e(TAG, "Exception creating AEC: $e") } if (agcAvailable) try { agc = AutomaticGainControl.create(sessionId) if (agc != null) { agc!!.enabled = true Log.d(TAG, "AGC is ${if (agc!!.enabled) "enabled" else "not enabled"}") } } catch (e: Exception) { Log.e(TAG, "Exception creating AGC: $e") } if (nsAvailable) try { ns = NoiseSuppressor.create(sessionId) if (ns != null) { ns!!.enabled = true Log.d(TAG, "NS is ${if (ns!!.enabled) "enabled" else "failed to enable"}") } } catch (e: Exception) { Log.e(TAG, "Exception creating NS: $e") } } return } val ua = UserAgent.ofUap(uap) if (ua == null) { Log.w(TAG, "uaEvent $event did not find ua $uap") return } val aor = ua.account.aor Log.d(TAG, "got uaEvent $event/$aor/$callp") val call = Call.ofCallp(callp) if (call == null && callp != 0L && !setOf("incoming call", "call incoming", "call closed").contains(ev[0])) { Log.w(TAG, "uaEvent $event did not find call $callp") return } for (accountIndex in uas.value.indices) { if (uas.value[accountIndex].account.aor == aor) { when (ev[0]) { "registering", "unregistering" -> { ua.updateStatus(circleYellow.getValue(colorblind)) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) return } "registered" -> { ua.updateStatus( if (Api.account_regint(ua.account.accp) == 0) R.drawable.circle_white else circleGreen.getValue(colorblind) ) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) return } "registering failed" -> { ua.updateStatus( if (Api.account_regint(ua.account.accp) == 0) R.drawable.circle_white else circleRed.getValue(colorblind) ) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) if (Utils.isVisible()) { val reason = if (ev.size > 1) { if (ev[1] == "Invalid argument") // Likely due to DNS lookup failure ": DNS lookup failed" else ": ${ev[1]}" } else "" toast(String.format(getString(R.string.registering_failed), aor) + reason) } return } "call outgoing" -> { if (call!!.status.value == "transferring") break speakerPhone = speakerPhoneAuto stopMediaPlayer() setCallVolume() ensureCommunicationMode() proximitySensing(proximitySensing) } "call ringing" -> { playRingBack() return } "call progress" -> { if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0) stopMediaPlayer() else playRingBack() return } "incoming call" -> { speakerPhone = speakerPhoneAuto val peerUri = ev[1] var blockedCall = false val toastMsg = if (Call.isAnyCallActive(this)) String.format( getString(R.string.call_auto_rejected), Utils.friendlyUri(this, peerUri, ua.account) ) else if (ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri) { blockedCall = true String.format( getString(R.string.call_blocked), Utils.friendlyUri(this, peerUri, ua.account) ) } else if (ua.account.blockHidden && peerUri.contains("anonymous")) { blockedCall = true getString(R.string.hidden_call_blocked) } else if (isBlocked(peerUri)) { blockedCall = true String.format( getString(R.string.call_blocked), Utils.friendlyUri(this, peerUri, ua.account) ) } else if (!Utils.checkPermissions(this, arrayOf(RECORD_AUDIO))) getString(R.string.no_calls) else "" if (toastMsg != "") { Log.d(TAG, "Auto-rejecting incoming call to $uap from $peerUri") Api.sip_treply(callp, 486, "Busy Here") Api.bevent_stop(ev[2].toLong()) toast(toastMsg) if (blockedCall) { if (ua.account.callHistory) Blocked( ua.account.aor, peerUri, "invite", GregorianCalendar().timeInMillis ).add() } else { val name = "callwaiting_$toneCountry" val resourceId = resources.getIdentifier( name, "raw", packageName ) if (resourceId != 0) ensureCommunicationMode() else Log.e(TAG, "Callwaiting tone $name.wav not found") if (ua.account.callHistory) { CallHistoryNew(aor, peerUri, "in").add() ua.account.missedCalls = true } } return } // callp holds SIP message pointer Api.ua_accept(uap, callp) return } "call incoming" -> { val peerUri = ev[1] Log.d(TAG, "Incoming call $uap/$callp/$peerUri") 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() ConnectionService.connections[callp]?.setActive() ensureCommunicationMode() Handler(Looper.getMainLooper()).post { if (call != null) { if (call.status.value == "incoming") call.status.value = "answered" else return@post } } } "call redirect" -> stopMediaPlayer() "call established" -> { ensureCommunicationMode() stopMediaPlayer() nm.cancel(CALL_NOTIFICATION_ID) Log.d(TAG, "AoR $aor call $callp established") Handler(Looper.getMainLooper()).post { if (call != null) { call.status.value = "connected" call.onhold = false call.startTime = GregorianCalendar() if (call.conferenceCall) Api.cmd_exec("conference") } updateStatusNotification() proximitySensing(proximitySensing) if (isMicMuted) setMicMute(true) } if (!isMainVisible) return } "call update" -> { Handler(Looper.getMainLooper()).post { if (call != null) { val newHeldState = when (ev[1].toInt()) { Api.SDP_INACTIVE, Api.SDP_RECVONLY -> true else -> false } val connection = ConnectionService.connections[callp] if (call.held && !newHeldState) { Log.d(TAG, "Call ${call.callp} un-held by peer.") call.onhold = false // Use a Coroutine with a small delay to let the SIP // transaction (the re-INVITE from the peer) finish // before trying to hold the other call and resume this one. CoroutineScope(Dispatchers.Main).launch { delay(100.milliseconds) call.resume() } } call.held = newHeldState if (newHeldState) { // Peer put us on hold call.showOnHoldNotice.value = true call.callOnHold.value = true if (connection?.state != Connection.STATE_HOLDING) connection?.setOnHold() } else { // Peer un-held us call.showOnHoldNotice.value = false if (!call.onhold) { call.callOnHold.value = false if (connection?.state != Connection.STATE_ACTIVE) connection?.setActive() } } if (call.state() == Api.CALL_STATE_EARLY) if ((ev[1].toInt() and Api.SDP_RECVONLY) != 0) stopMediaPlayer() if (call.status.value == "connected" && !call.held && !call.onhold) { if (call.callOnHold.value || call.showOnHoldNotice.value) { Log.d( TAG, "Safety guard: Clearing stuck hold flags for ${call.callp}" ) call.callOnHold.value = false call.showOnHoldNotice.value = false connection?.setActive() } } } } if (!isMainVisible || call?.status?.value != "connected") return } "call verified", "call secure" -> { Handler(Looper.getMainLooper()).post { if (call != null) { if (ev[0] == "call secure") call.security = R.color.colorTrafficYellow else { call.security = R.color.colorTrafficGreen call.zid = ev[1] } } } if (!isMainVisible) return } "call transfer" -> { if (!Utils.isVisible()) { val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK intent.putExtra("action", "transfer show") .putExtra("callp", callp).putExtra("uri", ev[1]) val pi = PendingIntent.getActivity( this, TRANSFER_REQ_CODE, intent, piFlags ) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) val target = Utils.friendlyUri(this, ev[1], ua.account) nb.setSmallIcon(R.drawable.ic_notification_call) .setColor(ContextCompat.getColor(this, R.color.colorPrimary)) .setContentIntent(pi) .setDefaults(Notification.DEFAULT_SOUND) .setAutoCancel(true) .setContentTitle(getString(R.string.transfer_request_to)) .setContentText(target) val acceptIntent = Intent(this, MainActivity::class.java) acceptIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK acceptIntent.putExtra("action", "transfer accept") .putExtra("callp", callp).putExtra("uri", ev[1]) val acceptPendingIntent = PendingIntent.getActivity( this, ACCEPT_REQ_CODE, acceptIntent, piFlags ) val denyIntent = Intent(this, BaresipService::class.java) denyIntent.action = "Transfer Deny" denyIntent.putExtra("callp", callp) val denyPendingIntent = PendingIntent.getService( this, DENY_REQ_CODE, denyIntent, piFlags) nb.addAction( R.drawable.ic_notification_call, getString(R.string.accept), acceptPendingIntent ) nb.addAction( R.drawable.ic_notification_call_end, getString(R.string.deny), denyPendingIntent ) nm.notify(TRANSFER_NOTIFICATION_ID, nb.build()) return } } "transfer failed" -> { Log.d(TAG, "AoR $aor call $callp transfer failed: ${ev[1]}") stopMediaPlayer() call!!.referTo = "" if (Utils.isVisible()) toast("${getString(R.string.transfer_failed)}: ${ev[1].trim()}") if (!isMainVisible) return } "call closed" -> { Log.d(TAG, "AoR $aor call $callp is closed prm: ${ev[1]}") nm.cancel(CALL_NOTIFICATION_ID) val connection = ConnectionService.connections[callp] if (connection != null) { val cause = when { ev[1].contains("200") -> DisconnectCause.LOCAL ev[1].contains("486") -> DisconnectCause.BUSY ev[1].contains("404") -> DisconnectCause.ERROR ev[1].contains("403") || ev[1].contains("401") -> DisconnectCause.RESTRICTED else -> DisconnectCause.REMOTE } connection.setDisconnected(DisconnectCause(cause)) connection.safeDestroy() ConnectionService.connections.remove(callp) } if (call != null) { call.terminated.value = true call.remove() val noMoreCalls = synchronized(calls) { !Call.inCall() } stopRinging() stopMediaPlayer() if (noMoreCalls) { proximitySensing(false) abandonAudioFocus(this) } val newCall = call.newCall if (newCall != null) { newCall.onHoldCall = null call.newCall = null } val onHoldCall = call.onHoldCall if (onHoldCall != null) { onHoldCall.newCall = null onHoldCall.referTo = "" call.onHoldCall = null } val isConference = call.conferenceCall val hasOtherCalls = synchronized(calls) { calls.any { it.ua == ua } } if (noMoreCalls) releaseAudioEffects() updateStatusNotification() if (isConference && !hasOtherCalls) { Log.d(TAG, "Last conference call closed, scheduling mixminus unload") Handler(Looper.getMainLooper()).postDelayed({ val conferenceActive = synchronized(calls) { calls.any { it.conferenceCall } } if (!conferenceActive) { Log.d(TAG, "Unloading mixminus module") Api.module_unload("mixminus") } }, 1000) } val reason = ev[1] val tone = ev[2] if (tone == "busy") playBusy() else ensureCommunicationMode() if (call.dir == "out") call.rejected = call.startTime == null && !reason.startsWith("408") && !reason.startsWith("480") && !reason.startsWith("Connection reset by") val missed = call.dir == "in" && call.startTime == null && !call.rejected if (ua.account.callHistory) { val completedElsewhere = missed && ev[2].startsWith("SIP") && ev[2].contains(";cause=200") CoroutineScope(Dispatchers.IO).launch { val history = CallHistoryNew(aor, call.peerUri, call.dir) history.stopTime = GregorianCalendar() history.startTime = if (completedElsewhere) history.stopTime else call.startTime history.rejected = call.rejected if (call.dumpfiles[0] != "") history.recording = call.dumpfiles history.add() if (call.startTime != null && call.dumpfiles[0] != "") { delay(500.milliseconds) val rxFile = File(call.dumpfiles[0]) val txFile = File(call.dumpfiles[1]) val mergedFileName = rxFile.name .replace("dump", "rec") .replace("=>", "-") .replace("sip:", "") .replace("-enc", "") .replace("*", "#") .replace(";user=phone", "") val mergedFile = File(filesPath, mergedFileName) if (Utils.mergeWavFiles(rxFile, txFile, mergedFile)) { Log.d(TAG, "Automatic merge succeeded.") history.recording = arrayOf(mergedFile.absolutePath, "") CallHistoryNew.save() try { rxFile.delete() txFile.delete() } catch (e: Exception) { Log.w( TAG, "Could not delete temporary raw files after merge: ${e.message}" ) } } else { Log.e( TAG, "Automatic merge failed. Storing raw file paths as fallback." ) history.recording = call.dumpfiles } } } ua.account.missedCalls = ua.account.missedCalls || missed } if (missed) { val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val caller = Utils.friendlyUri(this, call.peerUri, ua.account) val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK intent.putExtra("action", "call missed").putExtra("uap", uap) val pi = PendingIntent.getActivity( this, CALL_REQ_CODE, intent, piFlags ) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) nb.setSmallIcon(R.drawable.ic_notification_call_missed) .setColor(ContextCompat.getColor(this, R.color.colorPrimary)) .setContentIntent(pi) .setCategory(Notification.CATEGORY_CALL) .setAutoCancel(true) var missedCalls = 0 for (notification in nm.activeNotifications) if (notification.id == CALL_MISSED_NOTIFICATION_ID) missedCalls++ if (missedCalls == 0) { nb.setContentTitle(getString(R.string.missed_call_from)) nb.setContentText(caller) } else { nb.setContentTitle(getString(R.string.missed_calls)) nb.setContentText( String.format( getString(R.string.missed_calls_count), missedCalls + 1 ) ) } nm.notify(CALL_MISSED_NOTIFICATION_ID, nb.build()) } if (!Utils.isVisible()) return } val reason = ev[1].trim() if ((reason != "") && (ua.calls().isEmpty())) { if (reason[0].isDigit()) { if (reason[0] != '3') toast("${getString(R.string.call_failed)}: $reason") } else toast("${getString(R.string.call_closed)}: ${Api.call_peer_uri(callp)}: $reason") } } } break } } postServiceEvent( ServiceEvent(event, arrayListOf(uap, callp), System.nanoTime()) ) } @Suppress("unused") @SuppressLint("UnspecifiedImmutableFlag") @Keep fun messageEvent(uap: Long, peerUri: String, cType: String, msg: ByteArray) { val ua = UserAgent.ofUap(uap) if (ua == null) { Log.w(TAG, "messageEvent did not find ua $uap") return } val blockedHidden = ua.account.blockHidden && peerUri.contains("anonymous") val blockedUnknown = ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri if (blockedHidden || blockedUnknown) { Log.d(TAG, "Auto-rejecting incoming message by $uap from $peerUri") Blocked( ua.account.aor, peerUri, "message", GregorianCalendar().timeInMillis ).add() toast(if (blockedHidden) getString(R.string.hidden_message_blocked) else String.format( getString(R.string.message_blocked), Utils.friendlyUri(this, peerUri, ua.account) ) ) return } val charsetString = Utils.paramValue(cType.replace(" ", ""), "charset") val charset = try { Charset.forName(charsetString) } catch (_: Exception) { StandardCharsets.UTF_8 } val text = try { String(msg, charset) } catch (e: Exception) { val error = "Decoding of message failed using charset $charset from $cType: ${e.message}!" Log.w(TAG, error) error } handleIncomingMessage(uap, peerUri, text, System.currentTimeMillis()) } @SuppressLint("UnspecifiedImmutableFlag") fun handleIncomingMessage(uap: Long, peerUri: String, text: String, timeStamp: Long) { val ua = UserAgent.ofUap(uap) if (ua == null) { Log.w(TAG, "handleIncomingMessage did not find ua $uap") return } val aor = ua.account.aor if ((ua.account.blockUnknown && Contact.contactName(peerUri) == peerUri) || (ua.account.blockHidden && peerUri.contains("anonymous")) || isBlocked(peerUri)) { Log.d(TAG, "Auto-rejecting blocked message from $peerUri") toast( if (ua.account.blockHidden && peerUri.contains("anonymous")) getString(R.string.hidden_message_blocked) else String.format( getString(R.string.message_blocked), Utils.friendlyUri(this, peerUri, ua.account) ) ) Blocked( ua.account.aor, peerUri, "message", GregorianCalendar().timeInMillis ).add() return } // Check for duplicates val lastMsg = messages.lastOrNull { m -> m.aor == aor } if (lastMsg != null && lastMsg.timeStamp == timeStamp && lastMsg.peerUri == peerUri && lastMsg.message == text) { Log.d(TAG, "Omit duplicate message from $peerUri") return } Log.d(TAG, "Message event for $uap from $peerUri at $timeStamp") Message( aor, peerUri, text, timeStamp, MESSAGE_DOWN, 0, "", true ).add() ua.account.unreadMessages = true if (!Utils.isVisible()) { val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK intent.putExtra("action", "message show").putExtra("uap", uap).putExtra("peer", peerUri) val pi = PendingIntent.getActivity( this, MESSAGE_REQ_CODE, intent, piFlags ) val sender = createPerson(this, peerUri, ua.account) val localUserPerson = Person.Builder() .setName(getString(R.string.you)) .setKey(ua.account.aor) .build() val messagingStyle = MessagingStyle(localUserPerson) .setConversationTitle(null) .setGroupConversation(false) .addMessage(text, timeStamp, sender) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification_message) .setColor(ContextCompat.getColor(this, R.color.colorPrimary)) .setContentIntent(pi) .setSound(Settings.System.DEFAULT_NOTIFICATION_URI) .setAutoCancel(true) .setStyle(messagingStyle) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setPriority(NotificationCompat.PRIORITY_HIGH) .addPerson(sender) val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) .setLabel(getString(R.string.reply)) .build() val directReplyIntent = Intent(this, BaresipService::class.java) directReplyIntent.action = "Message Inline Reply" directReplyIntent.putExtra("uap", uap).putExtra("peer", peerUri).putExtra("time", timeStamp) val directReplyPendingIntent = PendingIntent.getService( this, DIRECT_REPLY_REQ_CODE, directReplyIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE ) val inlineReplyAction = NotificationCompat.Action.Builder( R.drawable.ic_notification_reply, getString(R.string.reply), directReplyPendingIntent ).addRemoteInput(remoteInput) .setAllowGeneratedReplies(true) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY). build() val saveIntent = Intent(this, BaresipService::class.java) saveIntent.action = "Message Save" saveIntent.putExtra("uap", uap) .putExtra("time", timeStamp.toString()) val savePendingIntent = PendingIntent .getService(this, SAVE_REQ_CODE, saveIntent, piFlags) val saveAction = NotificationCompat.Action.Builder( R.drawable.ic_notification_save, getString(R.string.save), savePendingIntent ).build() val deleteIntent = Intent(this, BaresipService::class.java) deleteIntent.action = "Message Delete" deleteIntent.putExtra("uap", uap) .putExtra("time", timeStamp.toString()) val deletePendingIntent = PendingIntent .getService(this, DELETE_REQ_CODE, deleteIntent, piFlags) val deleteAction = NotificationCompat.Action.Builder( R.drawable.ic_notification_delete, getString(R.string.delete), deletePendingIntent ).build() nb.addAction(inlineReplyAction).addAction(saveAction).addAction(deleteAction) nm.notify(MESSAGE_NOTIFICATION_ID, nb.build()) return } if (nm.currentInterruptionFilter <= NotificationManager.INTERRUPTION_FILTER_ALL) nt.play() postServiceEvent( ServiceEvent( "message show", arrayListOf(uap, peerUri), System.nanoTime() ) ) } @Keep @Suppress("UNUSED") fun messageResponse(responseCode: Int, responseReason: String, time: String) { Log.d(TAG, "Message response '$responseCode $responseReason' at $time") val timeStamp = time.toLong() for (m in messages.reversed()) if (m.timeStamp == timeStamp) { if (responseCode < 300) m.direction = MESSAGE_UP else { m.direction = MESSAGE_UP_FAIL m.responseCode = responseCode m.responseReason = responseReason } messageUpdate.postValue(System.currentTimeMillis()) break } else if (m.timeStamp < timeStamp - 60000) break } private var audioModeChangedListener: AudioManager.OnModeChangedListener? = null fun runCall(uap: Long, uri: String, conferenceCall: Boolean, onHoldCallp: Long) { val ua = UserAgent.ofUap(uap) if (ua != null && ua.account.isMobile && Utils.isUssd(uri)) { sendUssd(Utils.uriUserPart(uri)) return } val executeCall = { 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() updateStatusNotification() if (onHoldCall != null) onHoldCall.newCall = call if (!call.connect(uri)) { Log.w(TAG, "call_connect $callp failed") ConnectionService.onCallClosed(callp) call.remove() call.destroy() updateStatusNotification() } } else ConnectionService.pendingOutgoingConnection?.let { it.setDisconnected(DisconnectCause(DisconnectCause.ERROR)) it.destroy() ConnectionService.pendingOutgoingConnection = null } } }, audioDelay) } if (Call.hasTelecomCall()) executeCall() else if (VERSION.SDK_INT < 31) { Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") am.mode = MODE_IN_COMMUNICATION executeCall() } else { val isAnyCallMode = am.mode == MODE_IN_COMMUNICATION || am.mode == MODE_IN_CALL if (isAnyCallMode) { Log.d(TAG, "Audio mode already in a call mode (${am.mode})") executeCall() } else { audioModeChangedListener = AudioManager.OnModeChangedListener { mode -> if (mode == MODE_IN_COMMUNICATION || mode == MODE_IN_CALL) { Log.d(TAG, "Audio mode changed to $mode") audioModeChangedListener?.let { am.removeOnModeChangedListener(it) audioModeChangedListener = null } executeCall() } } am.addOnModeChangedListener(mainExecutor, audioModeChangedListener!!) Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION (waiting for callback)") am.mode = MODE_IN_COMMUNICATION } } } @Suppress("unused") @Keep fun started() { Log.d(TAG, "Received 'started' from baresip") isNativeReady = true Handler(Looper.getMainLooper()).post { addMobileUserAgent() if (VERSION.SDK_INT >= 29) updateMobileStatus() val mobileUa = uas.value.find { it.account.isMobile } if (mobileUa != null) synchronized(pendingMessages) { for (m in pendingMessages) handleIncomingMessage(mobileUa.uap, m.sender, m.body, m.time) pendingMessages.clear() } // Process any calls that arrived while baresip is starting InCallService.instance?.let { inCallService -> for (call in inCallService.calls) { val aor = call.details.intentExtras?.getString("aor") if (Call.ofCallp(call.hashCode().toLong()) == null) { Log.d(TAG, "Processing pending external call ${call.hashCode()}") handleExternalCall(call, aor) } } } } registerTelephony() Api.net_debug() postServiceEvent(ServiceEvent("started", arrayListOf(callActionUri), System.nanoTime())) callActionUri = "" Log.d(TAG, "Battery optimizations are ignored: " + "${pm.isIgnoringBatteryOptimizations(packageName)}") Log.d(TAG, "Partial wake lock/wifi lock is held: " + "${partialWakeLock.isHeld}/${wifiLock.isHeld}") updateStatusNotification() } fun registerTelephony() { if (VERSION.SDK_INT >= 31 && !telephonyCallbackRegistered) try { telephonyManager.registerTelephonyCallback(mainExecutor, telephonyCallback) telephonyCallbackRegistered = true Log.d(TAG, "Registered TelephonyCallback") } catch (e: Exception) { Log.e(TAG, "Failed to register TelephonyCallback: ${e.message}") } else if (!telephonyCallbackRegistered) { @Suppress("DEPRECATION") telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_SERVICE_STATE) telephonyCallbackRegistered = true Log.d(TAG, "Registered PhoneStateListener") } } @Suppress("unused") @Keep fun stopped(error: String) { Log.d(TAG, "Received 'stopped' from baresip with start error '$error'") isNativeReady = false quitTimer.cancel() cleanService() isServiceRunning = false postServiceEvent(ServiceEvent("stopped", arrayListOf(error), System.nanoTime())) stopSelf() } private fun createNotificationChannels() { val lowChannel = NotificationChannel( LOW_CHANNEL_ID, "No sound, no vibrate", NotificationManager.IMPORTANCE_LOW ) lowChannel.description = "Background status notifications" lowChannel.enableVibration(false) lowChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC nm.createNotificationChannel(lowChannel) val ringAttributes = AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .build() val highChannel = NotificationChannel( HIGH_CHANNEL_ID, "Sound, vibrate, and peek", NotificationManager.IMPORTANCE_HIGH ) highChannel.description = "Incoming calls and important alerts" highChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC highChannel.enableVibration(true) highChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, ringAttributes) nm.createNotificationChannel(highChannel) val mediumChannel = NotificationChannel( MEDIUM_CHANNEL_ID, "Sound only", NotificationManager.IMPORTANCE_DEFAULT ) mediumChannel.description = "Incoming messages" mediumChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC mediumChannel.enableVibration(false) mediumChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI, ringAttributes) nm.createNotificationChannel(mediumChannel) } private fun buildStatusNotification(): Notification { if (VERSION.SDK_INT >= 31) snb.foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE snb.setOngoing(true) val notification = snb.build() notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or Notification.FLAG_ONGOING_EVENT return notification } @SuppressLint("UnspecifiedImmutableFlag") private fun showStatusNotification() { val intent = Intent(this, MainActivity::class.java) .setAction(Intent.ACTION_MAIN) .addCategory(Intent.CATEGORY_LAUNCHER) val pi = PendingIntent.getActivity( this, 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 ) 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) .setCategory(Notification.CATEGORY_SERVICE) .setStyle(NotificationCompat.DecoratedCustomViewStyle()) .setCustomContentView(notificationLayout) val notification = buildStatusNotification() try { if (VERSION.SDK_INT >= 29) startForeground(STATUS_NOTIFICATION_ID, notification, foregroundServiceType()) else startForeground(STATUS_NOTIFICATION_ID, notification) } catch (e: Exception) { Log.e(TAG, "Failed to start foreground service: ${e.message}") // Fallback to standard notification if background start is denied nm.notify(STATUS_NOTIFICATION_ID, notification) } } fun updateStatusNotification() { Handler(Looper.getMainLooper()).post { val activeCall = synchronized(calls) { calls.find { it.status.value == "connected" || it.status.value == "outgoing" || it.status.value == "answered" } } val builder = NotificationCompat.Builder(this, LOW_CHANNEL_ID) val intent = Intent(this, MainActivity::class.java) .setAction(Intent.ACTION_MAIN) .addCategory(Intent.CATEGORY_LAUNCHER) val pi = PendingIntent.getActivity( this, 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 when (activeCall.status.value) { "outgoing", "incoming" -> getString(R.string.call_is_ringing) "connected", "answered" -> getString(R.string.call_is_connected) else -> getString(R.string.call) } ) } else { 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 builder.setOngoing(true) val notification = builder.build() notification.flags = notification.flags or Notification.FLAG_NO_CLEAR or Notification.FLAG_ONGOING_EVENT try { if (activeCall != null) { if (!isNotificationInCall) { // Only call startForeground when the FIRST call starts. if (VERSION.SDK_INT >= 29) startForeground( STATUS_NOTIFICATION_ID, notification, foregroundServiceType(activeCall) ) else startForeground(STATUS_NOTIFICATION_ID, notification) isNotificationInCall = true } else nm.notify(STATUS_NOTIFICATION_ID, notification) } else if (isNotificationInCall) { stopForeground(STOP_FOREGROUND_REMOVE) // Only call startForeground to drop the "Call" type when the LAST call ends. if (VERSION.SDK_INT >= 29) startForeground(STATUS_NOTIFICATION_ID, notification, foregroundServiceType()) else startForeground(STATUS_NOTIFICATION_ID, notification) isNotificationInCall = false } else // Already in standby, just keep the notification current. nm.notify(STATUS_NOTIFICATION_ID, notification) } catch (e: Exception) { Log.e(TAG, "Failed to update notification: ${e.message}") nm.notify(STATUS_NOTIFICATION_ID, notification) } } } private fun foregroundServiceType(activeCall: Call? = null): Int { var type = 0 if (VERSION.SDK_INT >= 29) { if (activeCall != null) { type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL if (VERSION.SDK_INT >= 30) { if (ContextCompat.checkSelfPermission(this, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE } } } if (VERSION.SDK_INT >= 34) type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE // Fallback for API 29-33 when no active call (specialUse not available) if (type == 0 && VERSION.SDK_INT >= 29) type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL return type } @SuppressLint("WakelockTimeout") private fun updatePartialWakeLock() { // Hold the wake lock as long as the service is active to ensure // native SIP timers (registration, keep-alives) continue to run. try { if (!partialWakeLock.isHeld) { Log.i(TAG, "Acquiring Partial Wake Lock") partialWakeLock.acquire() } } catch (e: Exception) { Log.e(TAG, "Error managing partialWakeLock: ${e.message}") } } fun handleExternalCall(telecomCall: android.telecom.Call, preferredAor: String? = null) { val rawUri = telecomCall.details.handle?.toString() ?: getString(R.string.unknown) val uri = Utils.uriUnescape(rawUri) if (uas.value.isEmpty()) { Log.e(TAG, "No User Agents available to handle external call") return } val ua = preferredAor?.let { UserAgent.ofAor(it) } ?: uas.value.find { it.account.isMobile } ?: uas.value[0] val telecomState = if (VERSION.SDK_INT >= 31) telecomCall.details.state else @Suppress("DEPRECATION") telecomCall.state val isIncoming = telecomState == android.telecom.Call.STATE_RINGING Log.d(TAG, "Handling external call " + "${if (isIncoming) "from" else "to"} $uri (preferredAor=$preferredAor)") val initialStatus = when (telecomState) { android.telecom.Call.STATE_RINGING -> "incoming" android.telecom.Call.STATE_DIALING, android.telecom.Call.STATE_CONNECTING -> "outgoing" else -> "connected" } if (isIncoming) { val e164Uri = e164Uri(uri, ua.account.countryCode) val presentation = telecomCall.details.handlePresentation if (ua.account.blockHidden && (presentation == TelecomManager.PRESENTATION_RESTRICTED || presentation == TelecomManager.PRESENTATION_UNKNOWN)) { Log.d(TAG, "Auto-rejecting incoming PSTN hidden call") telecomCall.disconnect() toast(getString(R.string.hidden_call_blocked)) if (ua.account.callHistory) Blocked( ua.account.aor, uri, "invite", GregorianCalendar().timeInMillis ).add() return } if (ua.account.blockUnknown && Contact.contactName(e164Uri) == e164Uri) { Log.d(TAG, "Auto-rejecting incoming PSTN call from $uri") telecomCall.disconnect() toast( String.format(getString(R.string.call_blocked), Utils.friendlyUri(this, uri, ua.account)) ) if (ua.account.callHistory) Blocked( ua.account.aor, uri, "invite", GregorianCalendar().timeInMillis ).add() return } if (isBlocked(uri)) { Log.d(TAG, "Auto-rejecting incoming PSTN call from $uri by block rule") telecomCall.disconnect() toast( String.format(getString(R.string.call_blocked), Utils.friendlyUri(this, uri, ua.account)) ) if (ua.account.callHistory) Blocked( ua.account.aor, uri, "invite", GregorianCalendar().timeInMillis ).add() return } } val call = Call.ExternalCall( telecomCall, ua, uri, if (telecomState == android.telecom.Call.STATE_RINGING) "in" else "out", initialStatus ) telecomCall.registerCallback(object : android.telecom.Call.Callback() { override fun onStateChanged(call: android.telecom.Call, state: Int) { super.onStateChanged(call, state) val newStatus = when (state) { android.telecom.Call.STATE_RINGING -> "incoming" android.telecom.Call.STATE_DIALING, android.telecom.Call.STATE_CONNECTING -> "outgoing" android.telecom.Call.STATE_ACTIVE -> "connected" android.telecom.Call.STATE_DISCONNECTED, android.telecom.Call.STATE_DISCONNECTING -> "closed" android.telecom.Call.STATE_HOLDING -> { calls.find { it.callp == call.hashCode().toLong() }?.onhold = true "connected" } else -> "connected" } calls.find { it.callp == call.hashCode().toLong() }?.let { if (it.status.value != newStatus) { it.status.value = newStatus if (newStatus == "connected") { it.startTime = GregorianCalendar() nm.cancel(CALL_NOTIFICATION_ID) updateStatusNotification() } postServiceEvent(ServiceEvent( "call update", arrayListOf(it.ua.uap, it.callp), System.nanoTime()) ) if (newStatus == "closed") handleExternalCallRemoved(call) } } } }) synchronized(calls) { calls.add(call) } setCallVolume() ensureCommunicationMode() if (isIncoming) { handleIncomingCall(call) if (ua.account.answerMode == Api.ANSWERMODE_AUTO) { Log.d(TAG, "Auto-answering external call ${call.callp}") Handler(Looper.getMainLooper()).postDelayed({ call.answer() }, 2000) } } else postServiceEvent(ServiceEvent( "call outgoing", arrayListOf(ua.uap, call.callp), System.nanoTime()) ) } fun handleExternalCallRemoved(telecomCall: android.telecom.Call) { val callp = telecomCall.hashCode().toLong() nm.cancel(CALL_NOTIFICATION_ID) val call = calls.find { it.callp == callp } if (call != null) { val uap = call.ua.uap stopRinging() stopMediaPlayer() if (call.ua.account.callHistory) { val historyPeerUri = e164Uri(call.peerUri, call.ua.account.countryCode) val history = CallHistoryNew(call.ua.account.aor, historyPeerUri, call.dir) history.stopTime = GregorianCalendar() history.startTime = call.startTime history.rejected = call.rejected history.add() if (call.dir == "in" && call.startTime == null && !call.rejected) call.ua.account.missedCalls = true } synchronized(calls) { calls.remove(call) } postServiceEvent( ServiceEvent("call closed", arrayListOf(uap, callp), System.nanoTime()) ) } if (!Call.inCall()) { proximitySensing(false) stopMediaPlayer() } updateStatusNotification() messageUpdate.postValue(System.currentTimeMillis()) } fun addMobileUserAgent() { if (VERSION.SDK_INT >= 29) { val mobileAccountHandle = Utils.pstnAccountHandle(this) val existingMobileUa = uas.value.find { it.account.isMobile } if (existingMobileUa == null && mobileAccountHandle == null) return val mobileAor = "sip:mobile@pstn" val isAirplaneModeOn = Utils.isAirplaneModeOn(this) val isSimReady = isSimReady() val status = if (mobileAccountHandle == null || !isSimReady) R.drawable.circle_white else if (existingMobileUa?.status == circleGreen.getValue(colorblind)) circleGreen.getValue(colorblind) else if (isAirplaneModeOn) R.drawable.circle_white else circleRed.getValue(colorblind) if (existingMobileUa != null) { if (existingMobileUa.status != status) { Log.d(TAG, "Updating existing Mobile UA status to $status") existingMobileUa.updateStatus(status) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) } return } Log.d(TAG, "Injecting new virtual Mobile account: $mobileAor") val account = Account(0L, mobileAor) account.isMobile = true account.nickName = "Mobile" account.regint = 0 account.telProvider = "" val mobileUa = UserAgent(0L, account) mobileUa.status = status val updatedUas = uas.value.toMutableList() updatedUas.add(mobileUa) uas.value = updatedUas.toList() uasStatus.value = UserAgent.statusMap() registerTelephony() Account.saveAccounts() CallHistoryNew.save() Message.save() updateStatusNotification() } } private fun toast(message: String, length: Int = Toast.LENGTH_SHORT) { Handler(Looper.getMainLooper()).post { Toast.makeText(this, message, length).show() } } @RequiresApi(Build.VERSION_CODES.Q) private fun updateMobileStatus(newStatus: Int? = null) { uas.value.find { it.account.isMobile }?.let { ua -> val isAirplaneModeOn = Utils.isAirplaneModeOn(this) val isSimReady = isSimReady() val mobileAccountHandle = Utils.pstnAccountHandle(this) val status = newStatus ?: if (mobileAccountHandle == null || !isSimReady) R.drawable.circle_white else if (ua.status == circleGreen.getValue(colorblind)) ua.status else if (isAirplaneModeOn) R.drawable.circle_white else if (ua.status == R.drawable.circle_white) // Show "Red" (not yet in service) circleRed.getValue(colorblind) else ua.status if (ua.status != status) { Log.d(TAG, "Updating Mobile status to $status") ua.updateStatus(status) updateStatusNotification() if (isMainVisible) registrationUpdate.postValue(System.currentTimeMillis()) } } } private fun updateMobileStatusFromServiceState(state: Int) { if (state == previousMobileServiceState) return previousMobileServiceState = state if (VERSION.SDK_INT >= 29) { val isAirplaneModeOn = Utils.isAirplaneModeOn(this) val isSimReady = isSimReady() val mobileAccountHandle = Utils.pstnAccountHandle(this) val status = if (state == ServiceState.STATE_IN_SERVICE && isSimReady && mobileAccountHandle != null) circleGreen.getValue(colorblind) else if (mobileAccountHandle == null || isAirplaneModeOn || !isSimReady) R.drawable.circle_white else circleRed.getValue(colorblind) Log.d(TAG, "Mobile service state changed: $state, updating status to $status (Airplane=$isAirplaneModeOn)") updateMobileStatus(status) } } @SuppressLint("MissingPermission") private fun sendUssd(ussdCode: String) { val uap = uas.value.find { it.account.isMobile }?.uap ?: 0L val tm = getSystemService(TELEPHONY_SERVICE) as TelephonyManager try { tm.sendUssdRequest(ussdCode, object : TelephonyManager.UssdResponseCallback() { override fun onReceiveUssdResponse( telephonyManager: TelephonyManager, request: String, response: CharSequence ) { super.onReceiveUssdResponse(telephonyManager, request, response) Log.d(TAG, "USSD response for $request: $response") postServiceEvent(ServiceEvent("ussd response", arrayListOf(uap, request, response.toString()), System.nanoTime())) } override fun onReceiveUssdResponseFailed( telephonyManager: TelephonyManager, request: String, failureCode: Int ) { super.onReceiveUssdResponseFailed(telephonyManager, request, failureCode) Log.d(TAG, "USSD response for $request failed with code $failureCode") val error = when (failureCode) { TelephonyManager.USSD_RETURN_FAILURE -> getString(R.string.ussd_failed) TelephonyManager.USSD_ERROR_SERVICE_UNAVAIL -> getString(R.string.ussd_service_unavailable) else -> getString(R.string.ussd_unknown_error) } postServiceEvent(ServiceEvent("ussd fail", arrayListOf(uap, request, error), System.nanoTime())) } }, Handler(Looper.getMainLooper())) } catch (e: Exception) { Log.e(TAG, "Exception sending USSD request: ${e.message}") toast(getString(R.string.ussd_failed)) } } @SuppressLint("FullScreenIntentPolicy") fun handleIncomingCall(call: Call) { val ua = call.ua val peerUri = call.peerUri val callp = call.callp val callerNumber = Utils.uriUserPart(peerUri) if (call !is Call.ExternalCall && shouldStartRinging(callerNumber)) startRinging() if (!Utils.isVisible()) { val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val intent = Intent(this, 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( this, CALL_REQ_CODE, intent, piFlags ) val diverterUri = call.diverterUri() val contentText = if (diverterUri != "") "${getString(R.string.is_calling)} (${getString(R.string.diverted_by)} ${Utils.friendlyUri(this, diverterUri, ua.account)})" else getString(R.string.is_calling) val caller = createPerson(this, peerUri, ua.account) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) 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(contentText) .setWhen(System.currentTimeMillis()) .setShowWhen(true) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setPriority(NotificationCompat.PRIORITY_MAX) .addPerson(caller) .setFullScreenIntent(pi, true) val answerIntent = Intent(this, MainActivity::class.java) .putExtra("action", "call answer") .putExtra("callp", callp) val api = PendingIntent.getActivity( this, 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( caller, rpi, api )) nm.notify(CALL_NOTIFICATION_ID, nb.build()) } postServiceEvent(ServiceEvent( "call incoming", arrayListOf(ua.uap, callp), System.nanoTime() )) } private fun createPerson(ctx: Context, peerUri: String, account: Account): Person { val peer = Utils.friendlyUri(ctx, peerUri, account) val contact = Contact.findContact(peerUri) val contactColor = contact?.color() ?: "#B0B0B0" val initial = if (peer.isNotEmpty()) peer.take(1) else "?" val textAvatarBitmap = Utils.createTextAvatar(initial, contactColor) var icon = IconCompat.createWithBitmap(textAvatarBitmap) if (contact is Contact.BaresipContact) { if (contact.avatarImage != null) icon = IconCompat.createWithBitmap(contact.avatarImage!!.toCircle()) } else if (contact is Contact.AndroidContact) if (contact.thumbnailUri != null) try { val source = ImageDecoder.createSource(contentResolver, contact.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") } return Person.Builder().setName(peer).setIcon(icon).build() } private fun startRinging() { am.mode = AudioManager.MODE_RINGTONE rt!!.isLooping = true rt!!.play() if (shouldVibrate()) { val effect = VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE) vbTimer = Timer() vbTimer!!.schedule(object : TimerTask() { override fun run() { if (VERSION.SDK_INT >= 33) vibrator.vibrate( effect, android.os.VibrationAttributes.Builder() .setUsage(android.os.VibrationAttributes.USAGE_RINGTONE) .build() ) else { val attributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() @Suppress("DEPRECATION") vibrator.vibrate(effect, attributes) } } }, 500L, 2000L) } } private fun shouldStartRinging(callerNumber: String): Boolean { val currentFilter = nm.currentInterruptionFilter if (currentFilter <= NotificationManager.INTERRUPTION_FILTER_ALL) return true val channel = nm.getNotificationChannel(HIGH_CHANNEL_ID) if (channel != null && channel.canBypassDnd()) return true return isStarredContact(callerNumber) } private fun shouldVibrate(): Boolean { // 1. If the phone is in Silent mode, never vibrate if (am.ringerMode == AudioManager.RINGER_MODE_SILENT) return false // 2. If the phone is in Vibrate mode, always vibrate if (am.ringerMode == AudioManager.RINGER_MODE_VIBRATE) return true // 3. If the phone is in Normal (Ringing) mode: // 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. return try { @Suppress("DEPRECATION") Settings.System.getInt( contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0 ) != 0 } catch (_: Exception) { // If the setting can't be read, default to FALSE. false } } private fun isStarredContact(callerNumber: String): Boolean { if (contactsMode == "baresip" || callerNumber.isBlank()) return false val phoneUri = Uri.withAppendedPath( ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(callerNumber) ) val projection = arrayOf(ContactsContract.PhoneLookup.STARRED) try { val cursor = contentResolver.query(phoneUri, projection, null, null, null) cursor?.use { if (it.moveToFirst()) { val isStarred = it.getInt(0) == 1 if (isStarred) Log.d(TAG, "Caller '$callerNumber' is a starred contact.") return isStarred } } } catch (e: Exception) { Log.e(TAG, "Could not query contacts for starred status: ${e.message}") } return false } private fun stopRinging() { rt!!.stop() if (vbTimer != null) { vbTimer!!.cancel() vbTimer = null } } @SuppressLint("DiscouragedApi") private fun playRingBack() { if (mediaPlayer == null) { val name = "ringback_$toneCountry" val resourceId = resources.getIdentifier(name, "raw", packageName) if (resourceId != 0) { val audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() mediaPlayer = MediaPlayer().apply { setAudioAttributes(audioAttributes) val afd = resources.openRawResourceFd(resourceId) setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) afd.close() isLooping = true prepare() start() } } else Log.e(TAG, "Ringback tone $name.wav not found") } } @SuppressLint("DiscouragedApi") private fun playBusy() { if (mediaPlayer == null) { val name = "busy_$toneCountry" val resourceId = resources.getIdentifier(name, "raw", packageName) if (resourceId != 0) { val audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() mediaPlayer = MediaPlayer().apply { setAudioAttributes(audioAttributes) val afd = resources.openRawResourceFd(resourceId) setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) afd.close() setOnCompletionListener { stopMediaPlayer() ensureCommunicationMode() } prepare() start() } } else Log.e(TAG, "Busy tone $name.wav not found") } } private fun stopMediaPlayer() { mediaPlayer?.stop() mediaPlayer?.release() mediaPlayer = null } private fun setCallVolume() { if (callVolume != 0) for (streamType in listOf(AudioManager.STREAM_MUSIC, AudioManager.STREAM_VOICE_CALL)) { origVolume[streamType] = am.getStreamVolume(streamType) val maxVolume = am.getStreamMaxVolume(streamType) am.setStreamVolume( streamType, (callVolume * 0.1 * maxVolume).roundToInt(), 0 ) Log.d(TAG, "Orig/new/max $streamType volume is " + "${origVolume[streamType]}/${am.getStreamVolume(streamType)}/$maxVolume") } } private fun resetCallVolume() { if (callVolume != 0) for ((streamType, streamVolume) in origVolume) { am.setStreamVolume(streamType, streamVolume, 0) Log.d(TAG, "Reset $streamType volume to ${am.getStreamVolume(streamType)}") } } private fun ensureCommunicationMode() { val currentMode = am.mode val isAnyCallMode = currentMode == MODE_IN_COMMUNICATION || currentMode == MODE_IN_CALL val isSpeakerphoneOn = if (VERSION.SDK_INT >= 31) am.communicationDevice?.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else { @Suppress("DEPRECATION") am.isSpeakerphoneOn } if (Call.inCall() && isAnyCallMode) { cleanupRunnable?.let { Log.d(TAG, "Canceling pending speakerphone cleanup because call is active") Handler(Looper.getMainLooper()).removeCallbacks(it) cleanupRunnable = null } if (isSpeakerphoneOn == speakerPhone) { if (Call.hasTelecomCall() || currentMode == MODE_IN_COMMUNICATION) { Log.d(TAG, "Already in valid call mode ($currentMode) " + "with correct speaker state.") return } } } else if (!Call.inCall() && currentMode == MODE_NORMAL) { if (speakerPhone) { Log.d(TAG, "Resetting speakerPhone UI state while idle") speakerPhone = false postServiceEvent( ServiceEvent( "speaker update,false", arrayListOf(0L, 0L), System.nanoTime() ) ) } return } Log.d(TAG, "Scheduling ensureCommunicationMode (current: $currentMode) in 500ms " + "(target speaker: $speakerPhone)") val handler = Handler(Looper.getMainLooper()) cleanupRunnable?.let { handler.removeCallbacks(it) } val runnable = Runnable { cleanupRunnable = null if (Call.inCall()) { val hasTelecom = Call.hasTelecomCall() if (!hasTelecom && am.mode != MODE_IN_COMMUNICATION && am.mode != MODE_IN_CALL) { am.mode = MODE_IN_COMMUNICATION Log.d(TAG, "Manual Mode Guard: Setting MODE_IN_COMMUNICATON from ${am.mode}") } Log.d(TAG, "Applying speakerphone state: $speakerPhone") if (InCallService.instance != null) { Log.d(TAG, "Using InCallService for audio route: $speakerPhone") @Suppress("DEPRECATION") InCallService.instance!!.setAudioRoute( if (speakerPhone) android.telecom.CallAudioState.ROUTE_SPEAKER else android.telecom.CallAudioState.ROUTE_EARPIECE ) } else if (!hasTelecom) { Log.d(TAG, "No Telecom connection, using AudioManager for speaker") Utils.setSpeakerPhone(mainExecutor, am, speakerPhone) } else for (c in Call.calls()) { ConnectionService.setOutput(c.callp, speakerPhone) } } else { if (am.mode != MODE_NORMAL) { am.mode = MODE_NORMAL Log.d(TAG, "Manual Mode Guard: Resetting to MODE_NORMAL") Utils.clearCommunicationDevice(am) } if (speakerPhone) { Log.d(TAG, "Resetting speakerPhone runtime state after call") speakerPhone = false postServiceEvent( ServiceEvent( "speaker update,false", arrayListOf(0L, 0L), System.nanoTime() ) ) } if (isMicMuted) { setMicMute(false) postServiceEvent( ServiceEvent( "mic muted,false", arrayListOf(0L, 0L), System.nanoTime() ) ) } if (!Call.hasTelecomCall()) resetCallVolume() } } cleanupRunnable = runnable handler.postDelayed(runnable, 500) } fun toggleSpeakerphone() { speakerPhone = !speakerPhone // Notify the UI to update the icon color postServiceEvent( ServiceEvent("speaker update,$speakerPhone", arrayListOf(0L, 0L), System.nanoTime()) ) // Apply the new audio route ensureCommunicationMode() } @SuppressLint("WakelockTimeout", "Wakelock") private fun proximitySensing(enable: Boolean) { if (enable) { if (!proximityWakeLock.isHeld) { Log.d(TAG, "Acquiring proximity wake lock") proximityWakeLock.acquire() } else Log.d(TAG, "Proximity wake lock already acquired") } else if (proximityWakeLock.isHeld) { proximityWakeLock.release() Log.d(TAG, "Released proximity wake lock") } else Log.d(TAG, "Proximity wake lock is not held") } private fun updateNetwork() { if (!isNativeReady) return val dnsChanged = updateDnsServers() val addresses = linkAddresses() if (linkAddresses != addresses) Log.d(TAG, "Old/new link addresses $linkAddresses/$addresses") var added = 0 for (a in addresses) if (!linkAddresses.containsKey(a.key)) { if (Api.net_add_address_ifname(a.key, a.value) != 0) Log.e(TAG, "Failed to add address: $a") else added++ } var removed = 0 for (a in linkAddresses) if (!addresses.containsKey(a.key)) { if (Api.net_rm_address(a.key) != 0) Log.e(TAG, "Failed to remove address: $a") else removed++ } val active = cm.activeNetwork if (added != removed || activeNetwork != active || dnsChanged) Log.d(TAG, "Added/Removed = $added/$removed, " + "Old/New Active = $activeNetwork/$active, DNS Changed = $dnsChanged" ) if (added > 0 || removed > 0 || active != activeNetwork || dnsChanged) { Api.net_debug() linkAddresses = addresses activeNetwork = active Api.uag_reset_transp(register = true, reinvite = true) } 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() } } else if (wifiLock.isHeld) { Log.d(TAG, "Releasing WiFi Lock") wifiLock.release() } } private fun linkAddresses(): MutableMap { val addresses = mutableMapOf() synchronized(allNetworks) { for (n in allNetworks) { val caps = cm.getNetworkCapabilities(n) ?: continue if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND) || caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ) { val props = cm.getLinkProperties(n) ?: continue for (la in props.linkAddresses) if (la.scope == OsConstants.RT_SCOPE_UNIVERSE && props.interfaceName != null && la.address.hostAddress != null && afMatch(la.address.hostAddress!!)) addresses[la.address.hostAddress!!] = props.interfaceName!! } } } if (hotSpotIsEnabled) for ((k, v) in hotSpotAddresses) if (afMatch(k)) addresses[k] = v return addresses } private fun afMatch(address: String): Boolean { return when (addressFamily) { "" -> true "ipv4" -> address.contains(".") else -> address.contains(":") } } private fun updateDnsServers(): Boolean { if (isServiceRunning && !dynDns) return false val servers = mutableListOf() // Use DNS servers first from active network (if available) val activeNetwork = cm.activeNetwork if (activeNetwork != null) { val linkProps = cm.getLinkProperties(activeNetwork) if (linkProps != null) servers.addAll(linkProps.dnsServers) } // Then add DNS servers from the other networks for (n in allNetworks) { if (n == cm.activeNetwork) continue val linkProps = cm.getLinkProperties(n) if (linkProps != null) for (server in linkProps.dnsServers) if (!servers.contains(server)) servers.add(server) } // Update if change synchronized(dnsServers) { if (servers != dnsServers) { if (isServiceRunning && Config.updateDnsServers(servers) != 0) Log.w(TAG, "Failed to update DNS servers '${servers}'") else { // Log.d(TAG, "Updated DNS servers: '${servers}'") dnsServers = servers return true } } } return false } private fun registerAndroidContactsObserver() { if (!androidContactsObserverRegistered) try { contentResolver.registerContentObserver(ContactsContract.Contacts.CONTENT_URI, true, androidContactsObserver) androidContactsObserverRegistered = true } catch (e: SecurityException) { Log.i(TAG, "No Contacts permission: ${e.message}") } } private fun unRegisterAndroidContactsObserver() { if (androidContactsObserverRegistered) { contentResolver.unregisterContentObserver(androidContactsObserver) androidContactsObserverRegistered = false } } 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) } fun isSimReady(): Boolean { val tm = getSystemService(TELEPHONY_SERVICE) as TelephonyManager return tm.simState == TelephonyManager.SIM_STATE_READY } private fun cleanService() { if (isServiceClean) return if (simStateReceiverRegistered) { try { unregisterReceiver(simStateReceiver) } catch (_: IllegalArgumentException) {} simStateReceiverRegistered = false } if (hotSpotReceiverRegistered) { try { unregisterReceiver(hotSpotReceiver) } catch (_: IllegalArgumentException) {} hotSpotReceiverRegistered = false } if (bluetoothReceiverRegistered) { try { unregisterReceiver(bluetoothReceiver) } catch (_: IllegalArgumentException) {} bluetoothReceiverRegistered = false } if (airplaneModeReceiverRegistered) { try { unregisterReceiver(airplaneModeReceiver) } catch (_: IllegalArgumentException) {} airplaneModeReceiverRegistered = false } if (telephonyCallbackRegistered) { if (VERSION.SDK_INT >= 31) try { telephonyManager.unregisterTelephonyCallback(telephonyCallback) } catch (_: Exception) {} else { @Suppress("DEPRECATION") telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) } telephonyCallbackRegistered = 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(this) uas.value = emptyList() uasStatus.value = emptyMap() callHistory.clear() messages = emptyList() if (this::nm.isInitialized) nm.cancelAll() if (this::partialWakeLock.isInitialized && partialWakeLock.isHeld) partialWakeLock.release() if (this::proximityWakeLock.isInitialized && proximityWakeLock.isHeld) proximityWakeLock.release() if (this::wifiLock.isInitialized) wifiLock.release() if (this::networkCallback.isInitialized) try { cm.unregisterNetworkCallback(networkCallback) } catch (_: Exception) {} if (androidContactsObserverRegistered) { try { contentResolver.unregisterContentObserver(androidContactsObserver) } catch (_: Exception) {} androidContactsObserverRegistered = false } releaseAudioEffects() isServiceClean = true } private external fun baresipStart( path: String, addresses: String, logLevel: Int, software: String ) external fun baresipStop(force: Boolean) @SuppressLint("MutableCollectionMutableState") companion object { private const val TAG = "BaresipService" var instance: BaresipService? = null var isServiceRunning = false var isNativeReady = false var isStartReceived = false var isConfigInitialized = false var libraryLoaded = false var callVolume = 0 @Volatile var speakerPhone = false var speakerPhoneAuto = false var audioDelay = if (VERSION.SDK_INT < 31) 1500L else 500L var dynDns = false var filesPath = "" var pName = "" var logLevel = 2 var sipTrace = false var callActionUri = "" var isMainVisible = false var isMicMuted = false var isRecOn = false var toneCountry = "us" var proximitySensing = true val uas = mutableStateOf(emptyList()) val uasStatus = mutableStateOf(emptyMap()) var contacts by mutableStateOf(mutableListOf()) val baresipContacts = mutableStateOf(emptyList()) val androidContacts = mutableStateOf(emptyList()) val contactNames = mutableStateOf(emptyList()) val darkTheme = mutableStateOf(false) val dynamicColors = mutableStateOf(false) var messages by mutableStateOf(emptyList()) val messageUpdate = MutableLiveData() val registrationUpdate = MutableLiveData() val serviceEvent = MutableLiveData>() val serviceEvents = mutableListOf() val calls = ArrayList() var callHistory = ArrayList() var blocked = ArrayList() var blockRules = mutableListOf() fun isBlocked(uri: String): Boolean { for (rule in blockRules) if (rule.matches(uri)) return true return false } var contactsMode by mutableStateOf("baresip") var addressFamily = "" var dnsServers = listOf() // of those accounts that have auth username without auth password val aorPasswords = mutableMapOf() var aecAvailable = false var agcAvailable = false var nsAvailable = false private var aec: AcousticEchoCanceler? = null private var agc: AutomaticGainControl? = null private var ns: NoiseSuppressor? = null private var recorderSessionId = 0 private var btAdapter: BluetoothAdapter? = null private var audioFocusRequest: AudioFocusRequest? = null private fun releaseAudioEffects() { aec?.enabled = false aec?.release() aec = null agc?.enabled = false agc?.release() agc = null ns?.enabled = false ns?.release() ns = null recorderSessionId = 0 } var rt: Ringtone? = null var colorblind = false val circleGreen = mapOf( true to R.drawable.circle_green_blind, false to R.drawable.circle_green ) val circleYellow = mapOf( true to R.drawable.circle_yellow_blind, false to R.drawable.circle_yellow ) val circleRed = mapOf( true to R.drawable.circle_red_blind, false to R.drawable.circle_red ) 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 setMicMute(mute: Boolean) { instance?.let { val am = it.getSystemService(AUDIO_SERVICE) as AudioManager am.isMicrophoneMute = mute } isMicMuted = mute Api.calls_mute(mute) } fun postServiceEvent(event: ServiceEvent) { synchronized(serviceEvents) { serviceEvents.add(event) if (serviceEvents.size == 1) { Log.d(TAG, "Posted service event ${event.event} at ${event.timeStamp}") serviceEvent.postValue(Event(event.timeStamp)) } else Log.d(TAG, "Added service event ${event.event}") } } fun requestAudioFocus(ctx: Context): Boolean { Log.d(TAG, "Requesting audio focus") if (audioFocusRequest != null) { Log.d(TAG, "Already focused") return true } val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager val attributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build() audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) .setAudioAttributes(attributes) .setOnAudioFocusChangeListener { } .build() if (am.requestAudioFocus(audioFocusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) Log.d(TAG, "requestAudioFocus granted") else { Log.w(TAG, "requestAudioFocus denied") audioFocusRequest = null } return audioFocusRequest != null } fun abandonAudioFocus(ctx: Context) { if (audioFocusRequest != null) { Log.d(TAG, "Abandoning audio focus") val am = ctx.getSystemService(AUDIO_SERVICE) as AudioManager if (am.abandonAudioFocusRequest(audioFocusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) audioFocusRequest = null else Log.e(TAG, "Failed to abandon audio focus") } } 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) } } }