Files
2026-07-03 09:20:15 +03:00

730 lines
27 KiB
Kotlin

package com.tutpro.baresip
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build.VERSION
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar
enum class Result {
OK, ERROR, RESTART
}
fun NavGraphBuilder.audioScreenRoute(
navController: NavController,
) {
composable("audio") {
val ctx = LocalContext.current
AudioScreen(
onBack = { navController.navigateUp() },
checkOnClick = {
when (checkOnClick(ctx)) {
Result.OK -> navController.navigateUp()
Result.RESTART -> {
navController.previousBackStackEntry
?.savedStateHandle
?.set("audio_settings_result", true)
navController.navigateUp()
}
Result.ERROR -> {}
}
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AudioScreen(onBack: () -> Unit, checkOnClick: () -> Unit) {
Scaffold(
modifier = Modifier.fillMaxSize().imePadding(),
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
Spacer(Modifier.statusBarsPadding())
TopAppBar(
title = {
Text(text = stringResource(R.string.audio_settings), fontWeight = FontWeight.Bold)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
IconButton(onClick = checkOnClick) {
Icon(imageVector = Icons.Filled.Check, contentDescription = "Check")
}
},
)
}
}
) { contentPadding ->
AudioContent(contentPadding)
}
}
private var newCallVolume = BaresipService.callVolume
private var oldMicGain = ""
private var newMicGain = ""
private var oldSpeakerPhone = BaresipService.speakerPhone
private var newSpeakerPhone = oldSpeakerPhone
private var oldAudioModules = ArrayList<String>()
private var newAudioModules = mutableMapOf<String, Boolean>()
private var oldOpusBitrate = ""
private var newOpusBitrate = oldOpusBitrate
private var oldOpusPacketLoss = ""
private var newOpusPacketLoss = oldOpusPacketLoss
private var newAudioDelay = BaresipService.audioDelay.toString()
private var newToneCountry = BaresipService.toneCountry
private var newRingtoneUri = ""
private var save = false
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
@Composable
private fun AudioContent(contentPadding: PaddingValues) {
oldSpeakerPhone = Config.variable("speaker_phone") == "yes"
newSpeakerPhone = oldSpeakerPhone
oldAudioModules = Config.variables("module")
for (module in Config.audioModules)
newAudioModules[module] = oldAudioModules.contains("${module}.so")
oldOpusBitrate = Config.variable("opus_bitrate")
oldOpusPacketLoss = Config.variable("opus_packet_loss")
if (!BaresipService.agcAvailable)
oldMicGain = Config.variable("augain")
if (showAlert.value)
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
lastButtonText = stringResource(R.string.ok),
)
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(contentPadding)
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 4.dp)
.verticalScrollbar(scrollState)
.verticalScroll(state = scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Ringtone()
ToneCountry()
SpeakerPhone()
CallVolume()
MicGain()
AudioModules()
OpusBitRate()
OpusPacketLoss()
AudioDelay()
}
}
@Composable
private fun Ringtone() {
val ringToneTitle = stringResource(R.string.ringtone)
val selectRingToneMessage = stringResource(R.string.select_ringtone)
val ctx = LocalContext.current
var ringtoneUri by remember {
mutableStateOf(if (Preferences(ctx).ringtoneUri == "")
RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_RINGTONE).toString()
else
Preferences(ctx).ringtoneUri!!)
}
newRingtoneUri = ringtoneUri
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
val uri: Uri? = if (VERSION.SDK_INT >= 33)
result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
else
@Suppress("DEPRECATION")
result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
if (uri != null) {
ringtoneUri = uri.toString()
newRingtoneUri = ringtoneUri
}
}
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = ringToneTitle,
modifier = Modifier
.weight(1f)
.clickable {
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_TYPE,
RingtoneManager.TYPE_RINGTONE
)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_TITLE,
selectRingToneMessage
)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_EXISTING_URI,
ringtoneUri.toUri()
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
launcher.launch(intent)
},
fontSize = 18.sp,
)
}
}
@Composable
private fun ToneCountry() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val toneCountryTitle = stringResource(R.string.tone_country)
val toneCountryHelp = stringResource(R.string.tone_country_help)
Text(text = toneCountryTitle,
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = toneCountryTitle
alertMessage.value = toneCountryHelp
showAlert.value = true
},
fontSize = 18.sp
)
val isDropDownExpanded = remember {
mutableStateOf(false)
}
val countryNames = arrayListOf("BG", "BR", "DE", "CZ", "ES", "FI", "FR", "GB", "JP", "NO", "NZ", "SE", "RU", "US")
val countryValues = arrayListOf("bg", "br", "de", "cz", "es", "fi", "fr", "uk", "jp", "no", "nz", "se", "ru", "us")
val itemPosition = remember {
val index = countryValues.indexOf(BaresipService.toneCountry)
mutableIntStateOf(if (index != -1) index else countryValues.indexOf("us"))
}
Box {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
isDropDownExpanded.value = true
}
) {
Text(text = countryNames[itemPosition.intValue])
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
}
DropdownMenu(
expanded = isDropDownExpanded.value,
onDismissRequest = { isDropDownExpanded.value = false }
) {
countryNames.forEachIndexed { index, name ->
DropdownMenuItem(
text = { Text(text = name) },
onClick = {
isDropDownExpanded.value = false
itemPosition.intValue = index
newToneCountry = countryValues[index]
}
)
if (index < 10)
HorizontalDivider(thickness = 1.dp)
}
}
}
}
}
@Composable
private fun SpeakerPhone() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val speakerPhoneTitle = stringResource(R.string.speaker_phone)
val speakerPhoneHelp = stringResource(R.string.speaker_phone_help)
Text(text = speakerPhoneTitle,
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = speakerPhoneTitle
alertMessage.value = speakerPhoneHelp
showAlert.value = true
},
fontSize = 18.sp
)
var speakerPhone by remember { mutableStateOf(oldSpeakerPhone) }
Switch(
checked = speakerPhone,
onCheckedChange = {
speakerPhone = it
newSpeakerPhone = speakerPhone
}
)
}
}
@Composable
private fun CallVolume() {
Row(
Modifier.fillMaxWidth().padding(end=10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val defaultCallVolumeTitle = stringResource(R.string.default_call_volume)
val defaultCallVolumeHelp = stringResource(R.string.default_call_volume_help)
Text(text = defaultCallVolumeTitle,
modifier = Modifier.weight(1f)
.clickable {
alertTitle.value = defaultCallVolumeTitle
alertMessage.value = defaultCallVolumeHelp
showAlert.value = true
},
fontSize = 18.sp
)
val isDropDownExpanded = remember { mutableStateOf(false) }
val volNames = listOf("--", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
val volValues = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val itemPosition = remember {
val index = volValues.indexOf(BaresipService.callVolume)
mutableIntStateOf(if (index != -1) index else 0)
}
Box {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
isDropDownExpanded.value = true
}
) {
Text(text = volNames[itemPosition.intValue])
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
}
DropdownMenu(
expanded = isDropDownExpanded.value,
onDismissRequest = { isDropDownExpanded.value = false }
) {
volNames.forEachIndexed { index, vol ->
DropdownMenuItem(
text = { Text(text = vol) },
onClick = {
isDropDownExpanded.value = false
itemPosition.intValue = index
newCallVolume = volValues[index]
})
if (index < 10)
HorizontalDivider(thickness = 1.dp)
}
}
}
}
}
@Composable
private fun MicGain() {
if (!BaresipService.agcAvailable)
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val microphoneGainTitle = stringResource(R.string.microphone_gain)
val microphoneGainHelp = stringResource(R.string.microphone_gain_help)
var micGain by remember { mutableStateOf(oldMicGain) }
newMicGain = micGain
OutlinedTextField(
value = micGain,
placeholder = { Text(microphoneGainTitle) },
onValueChange = {
micGain = it
newMicGain = micGain
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = microphoneGainTitle
alertMessage.value = microphoneGainHelp
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp),
label = { Text(microphoneGainTitle) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun AudioModules() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
val audioModulesTitle = stringResource(R.string.audio_modules_title)
val audioModulesHelp = stringResource(R.string.audio_modules_help)
Text(text = audioModulesTitle,
fontSize = 18.sp,
modifier = Modifier.clickable {
alertTitle.value = audioModulesTitle
alertMessage.value = audioModulesHelp
showAlert.value = true
})
for (module in Config.audioModules) {
Row(horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 18.dp, end = 10.dp)
) {
Text(text = String.format(stringResource(R.string.bullet_item), module), fontSize = 18.sp)
Spacer(modifier = Modifier.weight(1f))
var checked by remember { mutableStateOf(oldAudioModules.contains("${module}.so")) }
Switch(
checked = checked,
onCheckedChange = {
checked = it
newAudioModules[module] = checked
}
)
}
}
}
}
@Composable
private fun OpusBitRate() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val opusBitRateTitle = stringResource(R.string.opus_bit_rate)
val opusBitRateHelp = stringResource(R.string.opus_bit_rate_help)
var opusBitrate by remember { mutableStateOf(oldOpusBitrate) }
newOpusBitrate = opusBitrate
OutlinedTextField(
value = opusBitrate,
placeholder = { Text(opusBitRateTitle) },
onValueChange = {
opusBitrate = it
newOpusBitrate = opusBitrate
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = opusBitRateTitle
alertMessage.value = opusBitRateHelp
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp),
label = { Text(opusBitRateTitle) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun OpusPacketLoss() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val opusPacketLossTitle = stringResource(R.string.opus_packet_loss)
val opusPacketLossHelp = stringResource(R.string.opus_packet_loss_help)
var opusPacketLoss by remember { mutableStateOf(oldOpusPacketLoss) }
newOpusPacketLoss = opusPacketLoss
OutlinedTextField(
value = opusPacketLoss,
placeholder = { Text(opusPacketLossTitle) },
onValueChange = {
opusPacketLoss = it
newOpusPacketLoss = opusPacketLoss
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = opusPacketLossTitle
alertMessage.value = opusPacketLossHelp
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp),
label = { Text(opusPacketLossTitle) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
@Composable
private fun AudioDelay() {
Row(
Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val audioDelayTitle = stringResource(R.string.audio_delay)
val audioDelayHelp = stringResource(R.string.audio_delay_help)
var audioDelay by remember { mutableStateOf(BaresipService.audioDelay.toString()) }
newAudioDelay = audioDelay
OutlinedTextField(
value = audioDelay,
placeholder = { Text(audioDelayTitle) },
onValueChange = {
audioDelay = it
newAudioDelay = audioDelay
},
modifier = Modifier
.fillMaxWidth()
.clickable {
alertTitle.value = audioDelayTitle
alertMessage.value = audioDelayHelp
showAlert.value = true
},
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp),
label = { Text(audioDelayTitle) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}
}
private fun checkOnClick(ctx: Context): Result {
var restart = false
save = false
if (Preferences(ctx).ringtoneUri != newRingtoneUri) {
Preferences(ctx).ringtoneUri = newRingtoneUri
BaresipService.rt = RingtoneManager.getRingtone(ctx, newRingtoneUri.toUri())
}
if (BaresipService.toneCountry != newToneCountry) {
BaresipService.toneCountry = newToneCountry
Config.replaceVariable("tone_country", newToneCountry)
save = true
}
if (BaresipService.callVolume != newCallVolume) {
BaresipService.callVolume = newCallVolume
Config.replaceVariable("call_volume", newCallVolume.toString())
save = true
}
if (!BaresipService.agcAvailable) {
var gain = newMicGain.trim()
if (!gain.contains("."))
gain = "$gain.0"
if (gain != oldMicGain) {
if (!checkMicGain(gain)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "${ctx.getString(R.string.invalid_microphone_gain)}: $gain."
showAlert.value = true
return Result.ERROR
}
if (gain == "1.0") {
Api.module_unload("augain")
Config.removeVariableValue("module", "augain.so")
Config.replaceVariable("augain", "1.0")
}
else {
if (oldMicGain == "1.0") {
if (Api.module_load("augain") != 0) {
alertTitle.value = ctx.getString(R.string.error)
alertMessage.value = ctx.getString(R.string.failed_to_load_module) + ": augain.so"
showAlert.value = true
return Result.ERROR
}
Config.addVariable("module", "augain.so")
}
Config.replaceVariable("augain", gain)
Api.cmd_exec("augain $gain")
}
save = true
}
}
if (newSpeakerPhone != oldSpeakerPhone) {
Config.replaceVariable("speaker_phone", if (newSpeakerPhone) "yes" else "no")
BaresipService.speakerPhoneAuto = newSpeakerPhone
save = true
}
for (module in Config.audioModules) {
if (newAudioModules[module] != null) {
if (newAudioModules[module]!!) {
if (!oldAudioModules.contains("${module}.so")) {
if (Api.module_load("${module}.so") != 0) {
alertTitle.value = ctx.getString(R.string.error)
alertMessage.value = "${ctx.getString(R.string.failed_to_load_module)}: ${module}.so"
showAlert.value = true
return Result.ERROR
}
Config.addVariable("module", "${module}.so")
save = true
}
}
else if (oldAudioModules.contains("${module}.so")) {
Api.module_unload("${module}.so")
Config.removeVariableValue("module", "${module}.so")
for (ua in BaresipService.uas.value)
ua.account.removeAudioCodecs(module)
Account.saveAccounts()
save = true
}
}
}
if (newOpusBitrate != oldOpusBitrate) {
if (!checkOpusBitRate(newOpusBitrate)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "${ctx.getString(R.string.invalid_opus_bitrate)}: $newOpusBitrate."
showAlert.value = true
return Result.ERROR
}
Config.replaceVariable("opus_bitrate", newOpusBitrate)
restart = true
save = true
}
if (newOpusPacketLoss != oldOpusPacketLoss) {
if (!checkOpusPacketLoss(newOpusPacketLoss)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = "${ctx.getString(R.string.invalid_opus_packet_loss)}: $newOpusPacketLoss"
showAlert.value = true
return Result.ERROR
}
Config.replaceVariable("opus_packet_loss", newOpusPacketLoss)
restart = true
save = true
}
val audioDelay = newAudioDelay.trim()
if (audioDelay != BaresipService.audioDelay.toString()) {
if (!checkAudioDelay(audioDelay)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_audio_delay), audioDelay)
showAlert.value = true
return Result.ERROR
}
Config.replaceVariable("audio_delay", audioDelay)
BaresipService.audioDelay = audioDelay.toLong()
save = true
}
if (save) Config.save()
return if (restart) Result.RESTART else Result.OK
}
private fun checkMicGain(micGain: String): Boolean {
val number =
try {
micGain.toDouble()
} catch (_: NumberFormatException) {
return false
}
return number >= 1.0
}
private fun checkOpusBitRate(opusBitRate: String): Boolean {
val number = opusBitRate.toIntOrNull() ?: return false
return (number >= 6000) && (number <= 510000)
}
private fun checkOpusPacketLoss(opusPacketLoss: String): Boolean {
val number = opusPacketLoss.toIntOrNull() ?: return false
return (number >= 0) && (number <= 100)
}
private fun checkAudioDelay(audioDelay: String): Boolean {
val number = audioDelay.toIntOrNull() ?: return false
return (number >= 100) && (number <= 3000)
}