diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a0d90fc..4c2b9d17 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -76,108 +76,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - = 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - Utils.addActivity("about") - - val aboutTitle = getString(R.string.about_title) - val aboutText = String.format(getString(R.string.about_text), - BuildConfig.VERSION_NAME) - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - AboutContent(aboutTitle, aboutText) { goBack() } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun AboutContent(title: String, text: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier.safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text(text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Localized description", - tint = LocalCustomColors.current.light - ) - } - }, - ) - - } - ) { contentPadding -> - Text( - text = AnnotatedString.Companion.fromHtml( - htmlString = text, - linkStyles = TextLinkStyles( - style = SpanStyle(color = LocalCustomColors.current.accent) - ) - ), - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 16.dp) - .padding(contentPadding) - .verticalScroll(rememberScrollState()) - .fillMaxSize() - .background(LocalCustomColors.current.background) - ) - } - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("about") - setResult(RESULT_CANCELED, Intent()) - finish() - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AboutScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/AboutScreen.kt new file mode 100644 index 00000000..983b57d9 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/AboutScreen.kt @@ -0,0 +1,96 @@ +package com.tutpro.baresip + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.fromHtml +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable + +fun NavGraphBuilder.aboutScreenRoute(navController: NavController) { + composable("about") { + AboutScreen(onBack = { navController.popBackStack() }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AboutScreen(onBack: () -> Unit) { + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.about_title), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = LocalCustomColors.current.light + ) + } + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0) + ) + } + } + ) { contentPadding -> + Text( + text = AnnotatedString.fromHtml( + htmlString = stringResource(R.string.about_text, BuildConfig.VERSION_NAME), + linkStyles = TextLinkStyles(SpanStyle(color = LocalCustomColors.current.accent)) + ), + color = LocalCustomColors.current.itemText, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .fillMaxSize() + ) + } +} + diff --git a/app/src/main/kotlin/com/tutpro/baresip/Account.kt b/app/src/main/kotlin/com/tutpro/baresip/Account.kt index 75b455f0..e4457b58 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Account.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Account.kt @@ -249,6 +249,14 @@ class Account(val accp: Long) { return res } + fun saveAccounts() { + var accounts = "" + for (a in accounts()) accounts = accounts + a.print() + "\n" + Utils.putFileContents(BaresipService.filesPath + "/accounts", + accounts.toByteArray(Charsets.UTF_8)) + // Log.d(TAG, "Saved accounts '${accounts}' to '${BaresipService.filesPath}/accounts'") + } + fun ofAor(aor: String): Account? { for (ua in BaresipService.uas.value) if (ua.account.aor == aor) return ua.account diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt deleted file mode 100644 index 53394faf..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AccountActivity.kt +++ /dev/null @@ -1,1833 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -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.fillMaxHeight -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.safeDrawingPadding -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.Check -import androidx.compose.material3.CircularProgressIndicator -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.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -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.LaunchedEffect -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.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.tutpro.baresip.BaresipService.Companion.uas -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.verticalScrollbar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserFactory -import java.io.StringReader -import java.net.URL -import java.util.Locale - -class AccountActivity : ComponentActivity() { - - private lateinit var mediaEncMap: Map - private lateinit var mediaNatMap: Map - private lateinit var dtmfModeMap: Map - private lateinit var answerModeMap: Map - private lateinit var redirectModeMap: Map - private lateinit var acc: Account - private lateinit var ua: UserAgent - private lateinit var aor: String - - private var kind: String? = null - private var reRegister = false - private var oldNickname = "" - private var newNickname = "" - private var oldDisplayname = "" - private var newDisplayname = "" - private var oldAuthUser = "" - private var newAuthUser = "" - private var oldAuthPass = "" - private var newAuthPass = "" - private var oldOutbound1 = "" - private var newOutbound1 = "" - private var oldOutbound2 = "" - private var newOutbound2 = "" - private var oldRegister = false - private var newRegister = false - private var oldRegInt = "" - private var newRegInt = "" - private var oldMediaEnc = "" - private var newMediaEnc = "" - private var oldMediaNat = "" - private var newMediaNat = "" - private var oldStunServer = "" - private var newStunServer = "" - private var oldStunUser = "" - private var newStunUser = "" - private var oldStunPass = "" - private var newStunPass = "" - private var oldRtcpMux = false - private var newRtcpMux = false - private var old100Rel = false - private var new100Rel = false - private var oldDtmfMode = 0 - private var newDtmfMode = 0 - private var oldAnswerMode = 0 - private var newAnswerMode = 0 - private var oldAutoRedirect = false - private var newAutoRedirect = false - private var oldVmUri = "" - private var newVmUri = "" - private var oldCountryCode = "" - private var newCountryCode = "" - private var oldTelProvider = "" - private var newTelProvider = "" - private var oldDefaultAccount = false - private var newDefaultAccount = false - private var newNumericKeypad = false - private var password = mutableStateOf("") - private var showPasswordDialog = mutableStateOf(false) - private var keyboardController: SoftwareKeyboardController? = null - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - private val showStun = mutableStateOf(false) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - aor = intent.getStringExtra("aor")!! - kind = intent.getStringExtra("kind") - - Utils.addActivity("account,$aor") - - ua = UserAgent.ofAor(aor)!! - acc = ua.account - - mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF", - "srtp-mand" to "SRTP-MAND", "srtp" to "SRTP", "" to "--") - - mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--") - - dtmfModeMap = mapOf(Api.DTMFMODE_RTP_EVENT to getString(R.string.dtmf_inband), - Api.DTMFMODE_SIP_INFO to getString(R.string.dtmf_info), - Api.DTMFMODE_AUTO to getString(R.string.dtmf_auto)) - - answerModeMap = mapOf(Api.ANSWERMODE_MANUAL to getString(R.string.manual), - Api.ANSWERMODE_AUTO to getString(R.string.auto)) - - redirectModeMap = mapOf(false to getString(R.string.manual), true to getString(R.string.auto)) - - setContent { - AppTheme { - keyboardController = LocalSoftwareKeyboardController.current - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - AccountScreen(kind) { goBack() } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun AccountScreen(kind: String?, navigateBack: () -> Unit) { - - var isConfigLoaded by remember { mutableStateOf(false) } - - LaunchedEffect(kind, acc) { - if (kind == "new") - initAccountFromConfig(acc) { isConfigLoaded = true } - else - isConfigLoaded = true - } - - val title = if (acc.nickName.value != "") - acc.nickName.value - else - acc.aor.substringAfter(":") - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton(onClick = { - updateAccount() - }) { - Icon( - imageVector = Icons.Filled.Check, - tint = LocalCustomColors.current.light, - contentDescription = "Check" - ) - } - } - ) - }, - content = { contentPadding -> - if (isConfigLoaded) - AccountContent(this, contentPadding) - else - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - ) - } - - @Composable - fun AccountContent(ctx: Context, contentPadding: PaddingValues) { - - oldNickname = acc.nickName.value - newNickname = oldNickname - oldDisplayname = acc.displayName - newDisplayname = oldDisplayname - oldAuthUser = acc.authUser - newAuthUser = oldAuthUser - if (BaresipService.aorPasswords[aor] == null && // check if OK - acc.authPass != NO_AUTH_PASS) { - oldAuthPass = acc.authPass - newAuthPass = oldAuthPass - } - if (acc.outbound.isNotEmpty()) { - oldOutbound1 = acc.outbound[0] - newOutbound1 = oldOutbound1 - if (acc.outbound.size > 1) { - oldOutbound2 = acc.outbound[1] - newOutbound2 = oldOutbound2 - } - } - oldRegister = acc.regint > 0 - newRegister = oldRegister - oldRegInt = acc.configuredRegInt.toString() - newRegInt = oldRegInt - oldMediaEnc = acc.mediaEnc - newMediaEnc = oldMediaEnc - oldMediaNat = acc.mediaNat - newMediaNat = oldMediaNat - showStun.value = oldMediaNat != "" - oldStunServer = acc.stunServer - newStunServer = oldStunServer - oldStunUser = acc.stunUser - newStunUser = oldStunUser - oldStunPass = acc.stunPass - newStunPass = oldStunPass - oldRtcpMux = acc.rtcpMux - newRtcpMux = oldRtcpMux - old100Rel = acc.rel100Mode == Api.REL100_ENABLED - new100Rel = old100Rel - oldDtmfMode = acc.dtmfMode - newDtmfMode = oldDtmfMode - oldAnswerMode = acc.answerMode - newAnswerMode = oldAnswerMode - oldAutoRedirect = acc.autoRedirect - newAutoRedirect = oldAutoRedirect - oldVmUri = acc.vmUri - newVmUri = oldVmUri - oldCountryCode = acc.countryCode - newCountryCode = oldCountryCode - oldTelProvider = acc.telProvider - newTelProvider = oldTelProvider - newNumericKeypad = acc.numericKeypad - oldDefaultAccount = UserAgent.findAorIndex(aor)!! == 0 - newDefaultAccount = oldDefaultAccount - - val scrollState = rememberScrollState() - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding) - .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 16.dp) - .verticalScrollbar(scrollState) - .verticalScroll(state = scrollState), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - AoR() - Nickname() - DisplayName() - AuthUser() - AuthPass() - Outbound() - Register() - RegInt() - AudioCodecs(ctx) - MediaEnc() - MediaNat() - StunServer() - StunUser() - StunPass() - RtcpMux() - Rel100() - Dtmf() - Answer() - Redirect() - Voicemail() - CountryCode() - TelProvider() - NumericKeypad() - DefaultAccount() - AskPassword(ctx) - } - } - - @Composable - private fun AoR() { - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - OutlinedTextField( - value = acc.luri, - enabled = false, - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - textStyle = TextStyle( - fontSize = 18.sp, - color = LocalCustomColors.current.itemText - ), - label = { - LabelText(text = stringResource(R.string.sip_uri), - fontWeight = FontWeight.Bold) - } - ) - } - } - - @Composable - private fun Nickname() { - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var nickName by remember { mutableStateOf(oldNickname) } - OutlinedTextField( - value = nickName, - placeholder = { Text(stringResource(R.string.nickname)) }, - onValueChange = { - nickName = it - newNickname = nickName - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.nickname) - alertMessage.value = getString(R.string.account_nickname_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.nickname)) }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Words, - keyboardType = KeyboardType.Text), - ) - } - } - - @Composable - private fun DisplayName() { - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var displayName by remember { mutableStateOf(oldDisplayname) } - OutlinedTextField( - value = displayName, - placeholder = { Text(stringResource(R.string.display_name)) }, - onValueChange = { - displayName = it - newDisplayname = displayName - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.display_name) - alertMessage.value = getString(R.string.display_name_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.display_name)) }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - keyboardType = KeyboardType.Text), - ) - } - } - - @Composable - private fun AuthUser() { - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var authUser by remember { mutableStateOf(oldAuthUser) } - OutlinedTextField( - value = authUser, - placeholder = { Text(stringResource(R.string.authentication_username)) }, - onValueChange = { - authUser = it - newAuthUser = authUser - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.authentication_username) - alertMessage.value = getString(R.string.authentication_username_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.authentication_username)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun AuthPass() { - val showPassword = remember { mutableStateOf(false) } - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var authPass by remember { mutableStateOf(oldAuthPass) } - OutlinedTextField( - value = authPass, - placeholder = { Text(stringResource(R.string.authentication_password)) }, - onValueChange = { - authPass = it - newAuthPass = authPass - }, - singleLine = true, - visualTransformation = if (showPassword.value) - VisualTransformation.None - else - PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { - showPassword.value = !showPassword.value - }) { - Icon( - if (showPassword.value) - ImageVector.vectorResource(R.drawable.visibility) - else - ImageVector.vectorResource(R.drawable.visibility_off), - contentDescription = "Visibility", - tint = LocalCustomColors.current.itemText - - ) - } - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.authentication_password) - alertMessage.value = getString(R.string.authentication_password_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.authentication_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun Outbound() { - Text(text = stringResource(R.string.outbound_proxies), - color = LocalCustomColors.current.itemText, - fontSize = 18.sp, - modifier = Modifier.padding(top=8.dp) - .clickable { - alertTitle.value = getString(R.string.outbound_proxies) - alertMessage.value = getString(R.string.outbound_proxies_help) - showAlert.value = true - } - ) - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var outbound1 by remember { mutableStateOf(oldOutbound1) } - OutlinedTextField( - value = outbound1, - placeholder = { Text(stringResource(R.string.sip_uri_of_proxy_server)) }, - onValueChange = { - outbound1 = it - newOutbound1 = outbound1 - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.sip_uri_of_proxy_server)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var outbound2 by remember { mutableStateOf(oldOutbound2) } - OutlinedTextField( - value = outbound2, - placeholder = { Text(stringResource(R.string.sip_uri_of_another_proxy_server)) }, - onValueChange = { - outbound2 = it - newOutbound2 = outbound2 - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.sip_uri_of_another_proxy_server)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - fun Register() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.register), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.register) - alertMessage.value = getString(R.string.register_help) - showAlert.value = true - }, - fontSize = 18.sp, - color = LocalCustomColors.current.itemText) - var register by remember { mutableStateOf(oldRegister) } - Switch( - checked = register, - onCheckedChange = { - register = it - newRegister = register - } - ) - } - } - - @Composable - private fun RegInt() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var regInt by remember { mutableStateOf(oldRegInt) } - OutlinedTextField( - value = regInt, - placeholder = { Text(stringResource(R.string.reg_int)) }, - onValueChange = { - regInt = it - newRegInt = regInt - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.reg_int) - alertMessage.value = getString(R.string.reg_int_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.reg_int)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - } - } - - @Composable - private fun AudioCodecs(ctx: Context) { - Row( - Modifier.fillMaxWidth().padding(top=12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.audio_codecs), - modifier = Modifier.weight(1f) - .clickable { - val i = Intent(ctx, CodecsActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putString("media", "audio") - i.putExtras(b) - startActivity(i) - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp, - fontWeight = FontWeight. Bold - ) - } - } - - @Composable - private fun MediaEnc() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.media_encryption), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.media_encryption) - alertMessage.value = getString(R.string.media_encryption_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { mutableStateOf(false) } - val mediaEnc = remember { mutableStateOf(oldMediaEnc) } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = mediaEncMap[mediaEnc.value]!!, - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - var index = 0 - mediaEncMap.forEach { - DropdownMenuItem(text = { - Text(text = it.value, - color = LocalCustomColors.current.itemText) - }, - onClick = { - isDropDownExpanded.value = false - mediaEnc.value = it.key - newMediaEnc = mediaEnc.value - }) - if (index < 4) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - index++ - } - } - } - } - } - - @Composable - private fun MediaNat() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.media_nat), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.media_nat) - alertMessage.value = getString(R.string.media_nat_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { mutableStateOf(false) } - val mediaNat = remember { mutableStateOf(oldMediaNat) } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = mediaNatMap[mediaNat.value]!!, - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - var index = 0 - mediaNatMap.forEach { - DropdownMenuItem(text = { - Text(text = it.value) - }, - onClick = { - isDropDownExpanded.value = false - mediaNat.value = it.key - newMediaNat = mediaNat.value - showStun.value = newMediaNat != "" - }) - if (index < 3) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - index++ - } - } - } - } - } - - @Composable - private fun StunServer() { - if (showStun.value) - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var stunServer by remember { mutableStateOf(oldStunServer) } - OutlinedTextField( - value = stunServer, - placeholder = { Text(stringResource(R.string.stun_server)) }, - onValueChange = { - stunServer = it - newStunServer = stunServer - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.stun_server) - alertMessage.value = getString(R.string.stun_server_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.stun_server)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun StunUser() { - if (showStun.value) - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var stunUser by remember { mutableStateOf(oldStunUser) } - OutlinedTextField( - value = stunUser, - placeholder = { Text(stringResource(R.string.stun_username)) }, - onValueChange = { - stunUser = it - newStunUser = stunUser - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.stun_username) - alertMessage.value = getString(R.string.stun_username_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.stun_username)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun StunPass() { - if (showStun.value) { - val showPassword = remember { mutableStateOf(false) } - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var stunPass by remember { mutableStateOf(oldStunPass) } - OutlinedTextField( - value = stunPass, - placeholder = { Text(stringResource(R.string.stun_password)) }, - onValueChange = { - stunPass = it - newStunPass = stunPass - }, - singleLine = true, - visualTransformation = if (showPassword.value) - VisualTransformation.None - else - PasswordVisualTransformation(), - trailingIcon = { - val (icon, iconColor) = if (showPassword.value) { - Pair( - ImageVector.vectorResource(R.drawable.visibility), - colorResource(id = R.color.colorAccent) - ) - } else { - Pair( - ImageVector.vectorResource(R.drawable.visibility_off), - colorResource(id = R.color.colorWhite) - ) - } - IconButton(onClick = { showPassword.value = !showPassword.value }) { - Icon( - icon, - contentDescription = "Visibility", - tint = iconColor - ) - } - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.stun_password) - alertMessage.value = getString(R.string.stun_password_help) - showAlert.value = true - }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText - ), - label = { LabelText(stringResource(R.string.stun_password)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - } - - @Composable - fun RtcpMux() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.rtcp_mux), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.rtcp_mux) - alertMessage.value = getString(R.string.rtcp_mux_help) - showAlert.value = true - }, - fontSize = 18.sp, - color = LocalCustomColors.current.itemText) - var rtcpMux by remember { mutableStateOf(oldRtcpMux) } - Switch( - checked = rtcpMux, - onCheckedChange = { - rtcpMux = it - newRtcpMux = rtcpMux - } - ) - } - } - - @Composable - fun Rel100() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.rel_100), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.rel_100) - alertMessage.value = getString(R.string.rel_100_help) - showAlert.value = true - }, - fontSize = 18.sp, - color = LocalCustomColors.current.itemText) - var rel100 by remember { mutableStateOf(old100Rel) } - Switch( - checked = rel100, - onCheckedChange = { - rel100 = it - new100Rel = rel100 - } - ) - } - } - - @Composable - private fun Dtmf() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.dtmf_mode), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.dtmf_mode) - alertMessage.value = getString(R.string.dtmf_mode_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { - mutableStateOf(false) - } - val dtmfMode = remember { mutableIntStateOf(oldDtmfMode) } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = dtmfModeMap[dtmfMode.intValue]!!, - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - var index = 0 - dtmfModeMap.forEach { - DropdownMenuItem(text = { - Text(text = it.value) - }, - onClick = { - isDropDownExpanded.value = false - dtmfMode.intValue = it.key - newDtmfMode = dtmfMode.intValue - }) - if (index < 2) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - index++ - } - } - } - } - } - - @Composable - private fun Answer() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.answer_mode), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.answer_mode) - alertMessage.value = getString(R.string.answer_mode_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { - mutableStateOf(false) - } - val answerMode = remember { mutableIntStateOf(oldAnswerMode) } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = answerModeMap[answerMode.intValue]!!, - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - var index = 0 - answerModeMap.forEach { - DropdownMenuItem(text = { Text(text = it.value) }, - onClick = { - isDropDownExpanded.value = false - answerMode.intValue = it.key - newAnswerMode = answerMode.intValue - }) - if (index < 1) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - index++ - } - } - } - } - } - - @Composable - private fun Redirect() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.redirect_mode), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.redirect_mode) - alertMessage.value = getString(R.string.redirect_mode_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { - mutableStateOf(false) - } - val autoRedirect = remember { mutableStateOf(oldAutoRedirect) } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = redirectModeMap[autoRedirect.value]!!, - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - var index = 0 - redirectModeMap.forEach { - DropdownMenuItem(text = { - Text(text = it.value) - }, - onClick = { - isDropDownExpanded.value = false - autoRedirect.value = it.key - newAutoRedirect = autoRedirect.value - }) - if (index < 1) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - index++ - } - } - } - } - } - - @Composable - private fun Voicemail() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var vmUri by remember { mutableStateOf(oldVmUri) } - OutlinedTextField( - value = vmUri, - placeholder = { Text(stringResource(R.string.voicemail_uri)) }, - onValueChange = { - vmUri = it - newVmUri = vmUri - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.voicemail_uri) - alertMessage.value = getString(R.string.voicemain_uri_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.voicemail_uri)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun CountryCode() { - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var countryCode by remember { mutableStateOf(oldCountryCode) } - OutlinedTextField( - value = countryCode, - placeholder = { Text(stringResource(R.string.country_code)) }, - onValueChange = { - countryCode = it - newCountryCode = countryCode - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.country_code) - alertMessage.value = getString(R.string.country_code_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.country_code)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun TelProvider() { - Row( - Modifier.fillMaxWidth().padding(top=8.dp, end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var telProvider by remember { mutableStateOf(oldTelProvider) } - OutlinedTextField( - value = telProvider, - placeholder = { Text(stringResource(R.string.telephony_provider)) }, - onValueChange = { - telProvider = it - newTelProvider = telProvider - }, - modifier = Modifier.fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.telephony_provider) - alertMessage.value = getString(R.string.telephony_provider_help) - showAlert.value = true }, - textStyle = TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.telephony_provider)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - fun NumericKeypad() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.numeric_keypad), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.numeric_keypad) - alertMessage.value = getString(R.string.numeric_keypad_help) - showAlert.value = true - }, - fontSize = 18.sp, - color = LocalCustomColors.current.itemText) - var numericKeypad by remember { mutableStateOf(acc.numericKeypad) } - Switch( - checked = numericKeypad, - onCheckedChange = { - numericKeypad = it - newNumericKeypad = numericKeypad - } - ) - } - } - - @Composable - fun DefaultAccount() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.default_account), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.default_account) - alertMessage.value = getString(R.string.default_account_help) - showAlert.value = true }, - fontSize = 18.sp, - color = LocalCustomColors.current.itemText) - var defaultAccount by remember { mutableStateOf(oldDefaultAccount) } - Switch( - checked = defaultAccount, - onCheckedChange = { - defaultAccount = it - newDefaultAccount = defaultAccount - } - ) - } - } - - @Composable - fun AskPassword(ctx: Context) { - if (showPasswordDialog.value) - CustomElements.PasswordDialog( - ctx = ctx, - showPasswordDialog = showPasswordDialog, - password = password, - keyboardController = keyboardController, - title = stringResource(R.string.authentication_password), - okAction = { - if (password.value != "") { - BaresipService.aorPasswords[acc.aor] = password.value - Api.account_set_auth_pass(acc.accp, password.value) - password.value = "" - reRegister = true - finishActivity() - } - }, - cancelAction = { - reRegister = true - finishActivity() - } - ) - } - - private fun updateAccount() { - - if (BaresipService.activities.indexOf("account,$aor") == -1) - return - - val nn = newNickname.trim() - if (nn != oldNickname) { - if (Account.checkDisplayName(nn)) { - if (nn == "" || Account.uniqueNickName(nn)) { - acc.nickName.value = nn - Log.d(TAG, "New nickname is ${acc.nickName.value}") - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.non_unique_account_nickname), nn) - showAlert.value = true - return - } - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_account_nickname), nn) - showAlert.value = true - return - } - } - - val dn = newDisplayname.trim() - if (dn != acc.displayName) { - if (Account.checkDisplayName(dn)) { - if (Api.account_set_display_name(acc.accp, dn) == 0) { - acc.displayName = Api.account_display_name(acc.accp) - Log.d(TAG, "New display name is ${acc.displayName}") - } else { - Log.e(TAG, "Setting of display name failed") - } - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_display_name), dn) - showAlert.value = true - return - } - } - - val au = newAuthUser.trim() - if (au != oldAuthUser) { - if (Account.checkAuthUser(au)) { - if (Api.account_set_auth_user(acc.accp, au) == 0) { - acc.authUser = Api.account_auth_user(acc.accp) - Log.d(TAG, "New auth user is ${acc.authUser}") - if (acc.regint > 0) - reRegister = true - } - else { - Log.e(TAG, "Setting of auth user failed") - } - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_authentication_username), au) - showAlert.value = true - return - } - } - - val ap = newAuthPass.trim() - if (ap != "") { - if (ap != oldAuthPass) { - if (Account.checkAuthPass(ap)) { - if (Api.account_set_auth_pass(acc.accp, ap) == 0) { - acc.authPass = Api.account_auth_pass(acc.accp) - if (acc.regint > 0) - reRegister = true - } - else - Log.e(TAG, "Setting of auth pass failed") - BaresipService.aorPasswords.remove(acc.aor) - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_authentication_password), ap) - showAlert.value = true - return - } - } - else - BaresipService.aorPasswords.remove(acc.aor) - } - else { // ap == "" - if (acc.authPass != NO_AUTH_PASS && - acc.authPass != BaresipService.aorPasswords[acc.aor]) - if (Api.account_set_auth_pass(acc.accp, "") == 0) { - acc.authPass = NO_AUTH_PASS - BaresipService.aorPasswords[acc.aor] = NO_AUTH_PASS - } - } - - val ob = ArrayList() - var ob1 = newOutbound1.trim().replace(" ", "") - if (ob1 != "") { - if (!ob1.startsWith("sip:")) - ob1 = "sip:$ob1" - if (checkOutboundUri(ob1)) { - ob.add(ob1) - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_proxy_server_uri), ob1) - showAlert.value = true - return - } - } - var ob2 = newOutbound2.trim().replace(" ", "") - if (ob2 != "") { - if (!ob2.startsWith("sip:")) - ob2 = "sip:$ob2" - if (checkOutboundUri(ob2)) - ob.add(ob2) - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_proxy_server_uri), ob2) - showAlert.value = true - return - } - } - if (ob != acc.outbound) { - for (i in 0..1) { - val uri = if (ob.size > i) - ob[i] - else - "" - if (Api.account_set_outbound(acc.accp, uri, i) != 0) - Log.e(TAG, "Setting of outbound proxy $i uri '$uri' failed") - } - Log.d(TAG, "New outbound proxies are $ob") - acc.outbound = ob - if (ob.isEmpty()) - Api.account_set_sipnat(acc.accp, "") - else - Api.account_set_sipnat(acc.accp, "outbound") - if (acc.regint > 0) - reRegister = true - } - - val regInt = newRegInt.trim().toInt() - if (regInt < 60 || regInt > 3600) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_reg_int), "$regInt") - showAlert.value = true - return - } - val reReg = (newRegister != acc.regint > 0) || - (newRegister && regInt != acc.configuredRegInt) - if (reReg) { - if (Api.account_set_regint(acc.accp, - if (newRegister) regInt else 0) != 0) { - Log.e(TAG, "Setting of regint failed") - } else { - acc.regint = Api.account_regint(acc.accp) - acc.configuredRegInt = regInt - Log.d(TAG, "New regint is ${acc.regint}") - reRegister = true - } - } else { - if (regInt != acc.configuredRegInt) { - acc.configuredRegInt = regInt - } - } - - if (newMediaEnc != acc.mediaEnc) { - if (Api.account_set_mediaenc(acc.accp, newMediaEnc) == 0) { - acc.mediaEnc = Api.account_mediaenc(acc.accp) - Log.d(TAG, "New mediaenc is ${acc.mediaEnc}") - } else { - Log.e(TAG, "Setting of mediaenc $newMediaEnc failed") - } - } - - if (newMediaNat != acc.mediaNat) { - if (Api.account_set_medianat(acc.accp, newMediaNat) == 0) { - acc.mediaNat = Api.account_medianat(acc.accp) - Log.d(TAG, "New medianat is ${acc.mediaNat}") - } else { - Log.e(TAG, "Setting of medianat $newMediaNat failed") - } - } - - newStunServer = newStunServer.trim() - - if (newMediaNat != "") { - if (((newMediaNat == "stun") || (newMediaNat == "ice")) && (newStunServer == "")) - newStunServer = resources.getString(R.string.stun_server_default) - if (!Utils.checkStunUri(newStunServer) || - (newMediaNat == "turn" && - newStunServer.substringBefore(":") !in setOf("turn", "turns"))) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_stun_server), newStunServer) - showAlert.value = true - return - } - } - - if (acc.stunServer != newStunServer) { - if (Api.account_set_stun_uri(acc.accp, newStunServer) == 0) { - acc.stunServer = Api.account_stun_uri(acc.accp) - Log.d(TAG, "New STUN/TURN server URI is '${acc.stunServer}'") - } else { - Log.e(TAG, "Setting of STUN/TURN URI server failed") - } - } - - newStunUser = newStunUser.trim() - if (acc.stunUser != newStunUser) { - if (Account.checkAuthUser(newStunUser)) { - if (Api.account_set_stun_user(acc.accp, newStunUser) == 0) { - acc.stunUser = Api.account_stun_user(acc.accp) - Log.d(TAG, "New STUN/TURN user is ${acc.stunUser}") - } - else - Log.e(TAG, "Setting of STUN/TURN user failed") - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_stun_username), newStunUser) - showAlert.value = true - return - } - } - - val newStunPass = newStunPass.trim() - if (acc.stunPass != newStunPass) { - if (newStunPass.isEmpty() || Account.checkAuthPass(newStunPass)) { - if (Api.account_set_stun_pass(acc.accp, newStunPass) == 0) - acc.stunPass = Api.account_stun_pass(acc.accp) - else - Log.e(TAG, "Setting of stun pass failed") - } - else { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_stun_password), newStunPass) - showAlert.value = true - return - } - } - - if (newRtcpMux != acc.rtcpMux) - if (Api.account_set_rtcp_mux(acc.accp, newRtcpMux) == 0) { - acc.rtcpMux = Api.account_rtcp_mux(acc.accp) - Log.d(TAG, "New rtcpMux is ${acc.rtcpMux}") - } else { - Log.e(TAG, "Setting of account_rtc_mux $newRtcpMux failed") - } - - if (new100Rel != (acc.rel100Mode == Api.REL100_ENABLED)) { - val mode = if (new100Rel) Api.REL100_ENABLED else Api.REL100_DISABLED - if (Api.account_set_rel100_mode(acc.accp, mode) == 0) { - acc.rel100Mode = Api.account_rel100_mode(acc.accp) - Api.ua_update_account(ua.uap) - Log.d(TAG, "New rel100Mode is ${acc.rel100Mode}") - } else { - Log.e(TAG, "Setting of account_rel100Mode failed") - } - } - - if (newDtmfMode != acc.dtmfMode) { - if (Api.account_set_dtmfmode(acc.accp, newDtmfMode) == 0) { - acc.dtmfMode = Api.account_dtmfmode(acc.accp) - Log.d(TAG, "New dtmfmode is ${acc.dtmfMode}") - } else { - Log.e(TAG, "Setting of dtmfmode $newDtmfMode failed") - } - } - - if (newAnswerMode != acc.answerMode) { - if (Api.account_set_answermode(acc.accp, newAnswerMode) == 0) { - acc.answerMode = Api.account_answermode(acc.accp) - Log.d(TAG, "New answermode is ${acc.answerMode}") - } else { - Log.e(TAG, "Setting of answermode $newAnswerMode failed") - } - } - - if (newAutoRedirect != acc.autoRedirect) { - Api.account_set_sip_autoredirect(acc.accp, newAutoRedirect) - acc.autoRedirect = newAutoRedirect - Log.d(TAG, "New autoRedirect is ${acc.autoRedirect}") - } - - newVmUri = newVmUri.trim() - if (newVmUri != acc.vmUri) { - if (newVmUri != "") { - if (!newVmUri.startsWith("sip:")) newVmUri = "sip:$newVmUri" - if (!newVmUri.contains("@")) newVmUri = "$newVmUri@${acc.host()}" - if (!Utils.checkUri(newVmUri)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), newVmUri) - showAlert.value = true - return - } - Api.account_set_mwi(acc.accp, true) - } - else - Api.account_set_mwi(acc.accp, false) - acc.vmUri = newVmUri - Log.d(TAG, "New voicemail URI is ${acc.vmUri}") - } - - newCountryCode = newCountryCode.trim() - if (newCountryCode != acc.countryCode) { - if (newCountryCode != "" && !Utils.checkCountryCode(newCountryCode)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_country_code), newCountryCode) - showAlert.value = true - return - } - acc.countryCode = newCountryCode - Log.d(TAG, "New country code is ${acc.countryCode}") - } - - val hostPart = newTelProvider.trim() - if (hostPart != acc.telProvider) { - if (hostPart != "" && !Utils.checkHostPortParams(hostPart)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_sip_uri_hostpart), hostPart) - showAlert.value = true - return - } - acc.telProvider = hostPart - Log.d(TAG, "New tel provider is ${acc.telProvider}") - } - - if (newNumericKeypad != acc.numericKeypad) { - acc.numericKeypad = newNumericKeypad - Log.d(TAG, "New numericKeyboard is ${acc.numericKeypad}") - } - - val uaIndex = UserAgent.findAorIndex(aor)!! - if (newDefaultAccount && (uaIndex > 0)) { - val updatedUas = uas.value.toMutableList() - updatedUas.add(0, uas.value[uaIndex]) - updatedUas.removeAt(uaIndex + 1) - uas.value = updatedUas.toList() - } - - AccountsActivity.saveAccounts() - - if (acc.authUser != "" && BaresipService.aorPasswords[aor] == NO_AUTH_PASS) - showPasswordDialog.value = true - else - finishActivity() - } - - private fun finishActivity() { - if (reRegister) { - ua.status = R.drawable.circle_yellow - if (acc.regint == 0) - Api.ua_unregister(ua.uap) - else - Api.ua_register(ua.uap) - } - BaresipService.activities.remove("account,$aor") - returnResult(RESULT_OK) - } - - private fun goBack() { - BaresipService.activities.remove("account,$aor") - returnResult(RESULT_CANCELED) - } - - override fun onPause() { - MainActivity.activityAor = aor - super.onPause() - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun returnResult(code: Int) { - val i = Intent() - if (code == RESULT_OK) - i.putExtra("aor", aor) - setResult(code, i) - finish() - } - - private fun checkOutboundUri(uri: String): Boolean { - if (!uri.startsWith("sip:")) return false - return Utils.checkHostPortParams(uri.substring(4)) - } - - - private fun initAccountFromConfig(acc: Account, onConfigLoaded: () -> Unit) { - val scope = CoroutineScope(Job() + Dispatchers.Main) - scope.launch(Dispatchers.IO) { - val url = "https://${Utils.uriHostPart(acc.aor)}/baresip/account_config.xml" - val config = try { - URL(url).readText() - } catch (e: java.lang.Exception) { - Log.d(TAG, "Failed to get account configuration from network: ${e.message}") - null - } - if (config != null) { - Log.d(TAG, "Got account config '$config'") - val parserFactory: XmlPullParserFactory = XmlPullParserFactory.newInstance() - val parser: XmlPullParser = parserFactory.newPullParser() - parser.setInput(StringReader(config)) - var tag: String? - var text = "" - var event = parser.eventType - val audioCodecs = ArrayList(Api.audio_codecs().split(",")) - val videoCodecs = ArrayList(Api.video_codecs().split(",")) - - while (event != XmlPullParser.END_DOCUMENT) { - tag = parser.name - when (event) { - XmlPullParser.TEXT -> - text = parser.text - - XmlPullParser.START_TAG -> { - if (tag == "audio-codecs") - acc.audioCodec.clear() - if (tag == "video-codecs") - acc.videoCodec.clear() - } - - XmlPullParser.END_TAG -> - when (tag) { - "outbound-proxy-1" -> - if (text.isNotEmpty()) - acc.outbound.add(text) - - "outbound-proxy-2" -> - if (text.isNotEmpty()) - acc.outbound.add(text) - - "registration-interval" -> - acc.configuredRegInt = text.toInt() - - "register" -> - acc.regint = if (text == "yes") acc.configuredRegInt else 0 - - "audio-codec" -> - if (text in audioCodecs) - acc.audioCodec.add(text) - - "video-codec" -> - if (text in videoCodecs) - acc.videoCodec.add(text) - - "media-encoding" -> { - val enc = text.lowercase(Locale.ROOT) - if (enc in mediaEncMap.keys && enc.isNotEmpty()) - acc.mediaEnc = enc - } - - "media-nat" -> { - val nat = text.lowercase(Locale.ROOT) - if (nat in mediaNatMap.keys && nat.isNotEmpty()) - acc.mediaNat = nat - } - - "stun-turn-server" -> - if (text.isNotEmpty()) - acc.stunServer = text - - "rtcp-mux" -> - acc.rtcpMux = text == "yes" - - "100rel-mode" -> - acc.rel100Mode = if (text == "yes") - Api.REL100_ENABLED - else - Api.REL100_DISABLED - - "dtmf-mode" -> - if (text in arrayOf("rtp-event", "sip-info", "auto")) - acc.dtmfMode = when (text) { - "rtp-event" -> Api.DTMFMODE_RTP_EVENT - "sip-info" -> Api.DTMFMODE_SIP_INFO - else -> Api.DTMFMODE_AUTO - } - - "answer-mode" -> - if (text in arrayOf("manual", "auto")) - acc.answerMode = if (text == "manual") - Api.ANSWERMODE_MANUAL - else - Api.ANSWERMODE_AUTO - - "redirect-mode" -> - acc.autoRedirect = text == "yes" - - "voicemail-uri" -> - if (text.isNotEmpty()) - acc.vmUri = text - - "country-code" -> - acc.countryCode = text - - "tel-provider" -> - acc.telProvider = text - } - } - event = parser.next() - } - } - onConfigLoaded() - } - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountScreen.kt new file mode 100644 index 00000000..5e879f71 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/AccountScreen.kt @@ -0,0 +1,1885 @@ +package com.tutpro.baresip + +import android.content.Context +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +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.Check +import androidx.compose.material3.CircularProgressIndicator +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.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.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.verticalScrollbar +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader +import java.net.URL +import java.util.Locale + +fun NavGraphBuilder.accountScreenRoute(navController: NavController) { + composable( + route = "account/{aor}/{kind}", + arguments = listOf( + navArgument("aor") { type = NavType.StringType }, + navArgument("kind") { type = NavType.StringType } + ) + ) { backStackEntry -> + val ctx = LocalContext.current + val aor = backStackEntry.arguments?.getString("aor")!! + val kind = backStackEntry.arguments?.getString("kind")!! + val ua = UserAgent.ofAor(aor)!! + AccountScreen( + navController = navController, + onBack = { navController.popBackStack() }, + checkOnClick = { + val ok = checkOnClick(ctx, ua) + if (ok) { + if (reRegister) ua.reRegister() + navController.popBackStack() + } + }, + aor = aor, + kind = kind + ) + } +} + +private var keyboardController: SoftwareKeyboardController? = null + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AccountScreen( + navController: NavController, + onBack: () -> Unit, + checkOnClick: () -> Unit, + aor: String, + kind: String +) { + val ua = UserAgent.ofAor(aor)!! + val acc = ua.account + + var isConfigLoaded by remember { mutableStateOf(false) } + + LaunchedEffect(kind, acc) { + if (kind == "new") + initAccountFromConfig(acc) { isConfigLoaded = true } + else + isConfigLoaded = true + } + + val title = if (acc.nickName.value != "") + acc.nickName.value + else + acc.aor.substringAfter(":") + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = LocalCustomColors.current.light + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + actions = { + IconButton(onClick = checkOnClick) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ) + ) + } + } + ) { contentPadding -> + if (isConfigLoaded) + AccountContent(navController, contentPadding, ua) + else + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } +} + +private val password = mutableStateOf("") +private val showPasswordDialog = mutableStateOf(false) + +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + +private var oldNickname = "" +private var newNickname = "" +private var oldDisplayname = "" +private var newDisplayname = "" +private var oldAuthUser = "" +private var newAuthUser = "" +private var oldAuthPass = "" +private var newAuthPass = "" +private val oldOutbound1 = mutableStateOf("") +private val oldOutbound2 = mutableStateOf("") +private var newOutbound1 = "" +private var newOutbound2 = "" +private var oldRegister = false +private var newRegister = false +private var oldRegInt = "" +private var newRegInt = "" +private var oldMediaEnc = "" +private var newMediaEnc = "" +private var oldMediaNat = "" +private var newMediaNat = "" +private var oldStunServer = "" +private var newStunServer = "" +private var oldStunUser = "" +private var newStunUser = "" +private var oldStunPass = "" +private var newStunPass = "" +private var oldRtcpMux = false +private var newRtcpMux = false +private var old100Rel = false +private var new100Rel = false +private var oldDtmfMode = 0 +private var newDtmfMode = 0 +private var oldAnswerMode = 0 +private var newAnswerMode = 0 +private var oldAutoRedirect = false +private var newAutoRedirect = false +private var oldVmUri = "" +private var newVmUri = "" +private var oldCountryCode = "" +private var newCountryCode = "" +private var oldTelProvider = "" +private var newTelProvider = "" +private var oldDefaultAccount = false +private var newDefaultAccount = false +private var newNumericKeypad = false + +private var reRegister = false + +@Composable +private fun AccountContent( + navController: NavController, + contentPadding: PaddingValues, + ua: UserAgent +) { + val ctx = LocalContext.current + val acc = ua.account + val aor = acc.aor + + var mediaNatState by remember { mutableStateOf(oldMediaNat) } + val showStun by remember { derivedStateOf { mediaNatState != "" } } + + LaunchedEffect(acc) { + if (acc.outbound.isNotEmpty()) { + oldOutbound1.value = acc.outbound[0] + newOutbound1 = oldOutbound1.value + if (acc.outbound.size > 1) { + oldOutbound2.value = acc.outbound[1] + newOutbound2 = oldOutbound2.value + } else { + oldOutbound2.value = "" + newOutbound2 = "" + } + } else { + oldOutbound1.value = "" + newOutbound1 = "" + oldOutbound2.value = "" + newOutbound2 = "" + } + oldMediaNat = acc.mediaNat + newMediaNat = oldMediaNat + mediaNatState = oldMediaNat + } + + if (showAlert.value) { + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + } + + oldNickname = acc.nickName.value + newNickname = oldNickname + oldDisplayname = acc.displayName + newDisplayname = oldDisplayname + oldAuthUser = acc.authUser + newAuthUser = oldAuthUser + if (BaresipService.aorPasswords[aor] == null && acc.authPass != NO_AUTH_PASS) { + oldAuthPass = acc.authPass + newAuthPass = oldAuthPass + } + else { + oldAuthPass = "" + newAuthPass = "" + } + oldRegister = acc.regint > 0 + newRegister = oldRegister + oldRegInt = acc.configuredRegInt.toString() + newRegInt = oldRegInt + oldMediaEnc = acc.mediaEnc + newMediaEnc = oldMediaEnc + oldStunServer = acc.stunServer + newStunServer = oldStunServer + oldStunUser = acc.stunUser + newStunUser = oldStunUser + oldStunPass = acc.stunPass + newStunPass = oldStunPass + oldRtcpMux = acc.rtcpMux + newRtcpMux = oldRtcpMux + old100Rel = acc.rel100Mode == Api.REL100_ENABLED + new100Rel = old100Rel + oldDtmfMode = acc.dtmfMode + newDtmfMode = oldDtmfMode + oldAnswerMode = acc.answerMode + newAnswerMode = oldAnswerMode + oldAutoRedirect = acc.autoRedirect + newAutoRedirect = oldAutoRedirect + oldVmUri = acc.vmUri + newVmUri = oldVmUri + oldCountryCode = acc.countryCode + newCountryCode = oldCountryCode + oldTelProvider = acc.telProvider + newTelProvider = oldTelProvider + newNumericKeypad = acc.numericKeypad + oldDefaultAccount = UserAgent.findAorIndex(aor)!! == 0 + newDefaultAccount = oldDefaultAccount + + keyboardController = LocalSoftwareKeyboardController.current + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 16.dp) + .verticalScrollbar(scrollState) + .verticalScroll(state = scrollState), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AoR(acc) + Nickname() + DisplayName() + AuthUser() + AuthPass() + if (showPasswordDialog.value) + AskPassword(ctx, navController, ua) + Outbound() + Register() + RegInt() + AudioCodecs(navController, aor) + MediaEnc() + MediaNat( + currentMediaNat = mediaNatState, + onMediaNatSelected = { selectedMediaNat -> + mediaNatState = selectedMediaNat + newMediaNat = selectedMediaNat + } + ) + if (showStun) { + StunServer() + StunUser() + StunPass() + } + RtcpMux() + Rel100() + Dtmf() + Answer() + Redirect() + Voicemail() + CountryCode() + TelProvider() + NumericKeypad(acc) + DefaultAccount() + } +} + +@Composable +private fun AoR(acc: Account) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + OutlinedTextField( + value = acc.luri, + enabled = false, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + label = { + LabelText(text = stringResource(R.string.sip_uri), + fontWeight = FontWeight.Bold) + } + ) + } +} + +@Composable +private fun Nickname() { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var nickName by remember { mutableStateOf(oldNickname) } + OutlinedTextField( + value = nickName, + placeholder = { Text(stringResource(R.string.nickname)) }, + onValueChange = { + nickName = it + newNickname = nickName + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.nickname) + alertMessage.value = ctx.getString(R.string.account_nickname_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.nickname)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + keyboardType = KeyboardType.Text), + ) + } +} + +@Composable +private fun DisplayName() { + val ctx = LocalContext.current + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var displayName by remember { mutableStateOf(oldDisplayname) } + OutlinedTextField( + value = displayName, + placeholder = { Text(stringResource(R.string.display_name)) }, + onValueChange = { + displayName = it + newDisplayname = displayName + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.display_name) + alertMessage.value = ctx.getString(R.string.display_name_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.display_name)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text), + ) + } +} + +@Composable +private fun AuthUser() { + val ctx = LocalContext.current + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var authUser by remember { mutableStateOf(oldAuthUser) } + OutlinedTextField( + value = authUser, + placeholder = { Text(stringResource(R.string.authentication_username)) }, + onValueChange = { + authUser = it + newAuthUser = authUser + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.authentication_username) + alertMessage.value = ctx.getString(R.string.authentication_username_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.authentication_username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun AuthPass() { + val ctx = LocalContext.current + val showPassword = remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var authPass by remember { mutableStateOf(oldAuthPass) } + OutlinedTextField( + value = authPass, + placeholder = { Text(stringResource(R.string.authentication_password)) }, + onValueChange = { + authPass = it + newAuthPass = authPass + }, + singleLine = true, + visualTransformation = if (showPassword.value) + VisualTransformation.None + else + PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { + showPassword.value = !showPassword.value + }) { + Icon( + if (showPassword.value) + ImageVector.vectorResource(R.drawable.visibility) + else + ImageVector.vectorResource(R.drawable.visibility_off), + contentDescription = "Visibility", + tint = LocalCustomColors.current.itemText + + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.authentication_password) + alertMessage.value = ctx.getString(R.string.authentication_password_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.authentication_password)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun AskPassword(ctx: Context, navController: NavController, ua: UserAgent) { + CustomElements.PasswordDialog( + ctx = ctx, + showPasswordDialog = showPasswordDialog, + password = password, + keyboardController = keyboardController, + title = stringResource(R.string.authentication_password), + okAction = { + if (password.value != "") { + BaresipService.aorPasswords[ua.account.aor] = password.value + Api.account_set_auth_pass(ua.account.accp, password.value) + password.value = "" + ua.reRegister() + navController.popBackStack() + } + }, + cancelAction = { + ua.reRegister() + navController.popBackStack() + } + ) +} + +@Composable +private fun Outbound() { + val ctx = LocalContext.current + Text(text = stringResource(R.string.outbound_proxies), + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + modifier = Modifier + .padding(top = 8.dp) + .clickable { + alertTitle.value = ctx.getString(R.string.outbound_proxies) + alertMessage.value = ctx.getString(R.string.outbound_proxies_help) + showAlert.value = true + } + ) + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var outbound1 by remember { mutableStateOf(oldOutbound1) } + OutlinedTextField( + value = outbound1.value, + placeholder = { Text(stringResource(R.string.sip_uri_of_proxy_server)) }, + onValueChange = { + outbound1.value = it + newOutbound1 = outbound1.value + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.sip_uri_of_proxy_server)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var outbound2 by remember { mutableStateOf(oldOutbound2) } + OutlinedTextField( + value = outbound2.value, + placeholder = { Text(stringResource(R.string.sip_uri_of_another_proxy_server)) }, + onValueChange = { + outbound2.value = it + newOutbound2 = outbound2.value + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.sip_uri_of_another_proxy_server)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun Register() { + val ctx = LocalContext.current + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.register), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.register) + alertMessage.value = ctx.getString(R.string.register_help) + showAlert.value = true + }, + fontSize = 18.sp, + color = LocalCustomColors.current.itemText) + var register by remember { mutableStateOf(oldRegister) } + Switch( + checked = register, + onCheckedChange = { + register = it + newRegister = register + } + ) + } +} + +@Composable +private fun RegInt() { + val ctx = LocalContext.current + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + var regInt by remember { mutableStateOf(oldRegInt) } + OutlinedTextField( + value = regInt, + placeholder = { Text(stringResource(R.string.reg_int)) }, + onValueChange = { + regInt = it + newRegInt = regInt + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.reg_int) + alertMessage.value = ctx.getString(R.string.reg_int_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.reg_int)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } +} + +@Composable +private fun AudioCodecs(navController: NavController, aor: String) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.audio_codecs), + modifier = Modifier + .weight(1f) + .clickable { + val route = "codecs/$aor/audio" + navController.navigate(route) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + fontWeight = FontWeight. Bold + ) + } +} + +@Composable +private fun MediaEnc() { + val ctx = LocalContext.current + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.media_encryption), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.media_encryption) + alertMessage.value = ctx.getString(R.string.media_encryption_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { mutableStateOf(false) } + val mediaEnc = remember { mutableStateOf(oldMediaEnc) } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = mediaEncMap[mediaEnc.value]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + mediaEncMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value, + color = LocalCustomColors.current.itemText) + }, + onClick = { + isDropDownExpanded.value = false + mediaEnc.value = it.key + newMediaEnc = mediaEnc.value + }) + if (index < 4) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } +} + +@Composable +private fun MediaNat(currentMediaNat: String, onMediaNatSelected: (String) -> Unit) { + val ctx = LocalContext.current + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(text = stringResource(R.string.media_nat), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.media_nat) + alertMessage.value = ctx.getString(R.string.media_nat_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { mutableStateOf(false) } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = mediaNatMap[currentMediaNat]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + mediaNatMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value) + }, + onClick = { + isDropDownExpanded.value = false + onMediaNatSelected(it.key) + newMediaNat = it.key + }) + if (index < 3) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } +} + +@Composable +private fun StunServer() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var stunServer by remember { mutableStateOf(oldStunServer) } + OutlinedTextField( + value = stunServer, + placeholder = { Text(stringResource(R.string.stun_server)) }, + onValueChange = { + stunServer = it + newStunServer = stunServer + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.stun_server) + alertMessage.value = ctx.getString(R.string.stun_server_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.stun_server)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun StunUser() { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var stunUser by remember { mutableStateOf(oldStunUser) } + OutlinedTextField( + value = stunUser, + placeholder = { Text(stringResource(R.string.stun_username)) }, + onValueChange = { + stunUser = it + newStunUser = stunUser + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.stun_username) + alertMessage.value = ctx.getString(R.string.stun_username_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.stun_username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun StunPass() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + val showPassword = remember { mutableStateOf(false) } + var stunPass by remember { mutableStateOf(oldStunPass) } + OutlinedTextField( + value = stunPass, + placeholder = { Text(stringResource(R.string.stun_password)) }, + onValueChange = { + stunPass = it + newStunPass = stunPass + }, + singleLine = true, + visualTransformation = if (showPassword.value) + VisualTransformation.None + else + PasswordVisualTransformation(), + trailingIcon = { + val (icon, iconColor) = if (showPassword.value) { + Pair( + ImageVector.vectorResource(R.drawable.visibility), + colorResource(id = R.color.colorAccent) + ) + } else { + Pair( + ImageVector.vectorResource(R.drawable.visibility_off), + colorResource(id = R.color.colorWhite) + ) + } + IconButton(onClick = { showPassword.value = !showPassword.value }) { + Icon( + icon, + contentDescription = "Visibility", + tint = iconColor + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.stun_password) + alertMessage.value = ctx.getString(R.string.stun_password_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { LabelText(stringResource(R.string.stun_password)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun RtcpMux() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.rtcp_mux), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.rtcp_mux) + alertMessage.value = ctx.getString(R.string.rtcp_mux_help) + showAlert.value = true + }, + fontSize = 18.sp, + color = LocalCustomColors.current.itemText) + var rtcpMux by remember { mutableStateOf(oldRtcpMux) } + Switch( + checked = rtcpMux, + onCheckedChange = { + rtcpMux = it + newRtcpMux = rtcpMux + } + ) + } +} + +@Composable +private fun Rel100() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.rel_100), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.rel_100) + alertMessage.value = ctx.getString(R.string.rel_100_help) + showAlert.value = true + }, + fontSize = 18.sp, + color = LocalCustomColors.current.itemText) + var rel100 by remember { mutableStateOf(old100Rel) } + Switch( + checked = rel100, + onCheckedChange = { + rel100 = it + new100Rel = rel100 + } + ) + } +} + +@Composable +private fun Dtmf() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + val dtmfModeMap = mapOf(Api.DTMFMODE_RTP_EVENT to ctx.getString(R.string.dtmf_inband), + Api.DTMFMODE_SIP_INFO to ctx.getString(R.string.dtmf_info), + Api.DTMFMODE_AUTO to ctx.getString(R.string.dtmf_auto)) + Text(text = stringResource(R.string.dtmf_mode), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.dtmf_mode) + alertMessage.value = ctx.getString(R.string.dtmf_mode_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val dtmfMode = remember { mutableIntStateOf(oldDtmfMode) } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = dtmfModeMap[dtmfMode.intValue]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + dtmfModeMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value) + }, + onClick = { + isDropDownExpanded.value = false + dtmfMode.intValue = it.key + newDtmfMode = dtmfMode.intValue + }) + if (index < 2) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } +} + +@Composable +private fun Answer() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + val answerModeMap = mapOf(Api.ANSWERMODE_MANUAL to ctx.getString(R.string.manual), + Api.ANSWERMODE_AUTO to ctx.getString(R.string.auto)) + Text(text = stringResource(R.string.answer_mode), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.answer_mode) + alertMessage.value = ctx.getString(R.string.answer_mode_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val answerMode = remember { mutableIntStateOf(oldAnswerMode) } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = answerModeMap[answerMode.intValue]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + answerModeMap.forEach { + DropdownMenuItem(text = { Text(text = it.value) }, + onClick = { + isDropDownExpanded.value = false + answerMode.intValue = it.key + newAnswerMode = answerMode.intValue + }) + if (index < 1) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } +} + +@Composable +private fun Redirect() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + val redirectModeMap = mapOf(false to ctx.getString(R.string.manual), + true to ctx.getString(R.string.auto)) + Text(text = stringResource(R.string.redirect_mode), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.redirect_mode) + alertMessage.value = ctx.getString(R.string.redirect_mode_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val autoRedirect = remember { mutableStateOf(oldAutoRedirect) } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = redirectModeMap[autoRedirect.value]!!, + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + var index = 0 + redirectModeMap.forEach { + DropdownMenuItem(text = { + Text(text = it.value) + }, + onClick = { + isDropDownExpanded.value = false + autoRedirect.value = it.key + newAutoRedirect = autoRedirect.value + }) + if (index < 1) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + index++ + } + } + } + } +} + +@Composable +private fun Voicemail() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var vmUri by remember { mutableStateOf(oldVmUri) } + OutlinedTextField( + value = vmUri, + placeholder = { Text(stringResource(R.string.voicemail_uri)) }, + onValueChange = { + vmUri = it + newVmUri = vmUri + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.voicemail_uri) + alertMessage.value = ctx.getString(R.string.voicemain_uri_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.voicemail_uri)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun CountryCode() { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var countryCode by remember { mutableStateOf(oldCountryCode) } + OutlinedTextField( + value = countryCode, + placeholder = { Text(stringResource(R.string.country_code)) }, + onValueChange = { + countryCode = it + newCountryCode = countryCode + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.country_code) + alertMessage.value = ctx.getString(R.string.country_code_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.country_code)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun TelProvider() { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var telProvider by remember { mutableStateOf(oldTelProvider) } + OutlinedTextField( + value = telProvider, + placeholder = { Text(stringResource(R.string.telephony_provider)) }, + onValueChange = { + telProvider = it + newTelProvider = telProvider + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.telephony_provider) + alertMessage.value = ctx.getString(R.string.telephony_provider_help) + showAlert.value = true + }, + textStyle = TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.telephony_provider)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun NumericKeypad(acc: Account) { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.numeric_keypad), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.numeric_keypad) + alertMessage.value = ctx.getString(R.string.numeric_keypad_help) + showAlert.value = true + }, + fontSize = 18.sp, + color = LocalCustomColors.current.itemText) + var numericKeypad by remember { mutableStateOf(acc.numericKeypad) } + Switch( + checked = numericKeypad, + onCheckedChange = { + numericKeypad = it + newNumericKeypad = numericKeypad + } + ) + } +} + +@Composable +private fun DefaultAccount() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.default_account), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.default_account) + alertMessage.value = ctx.getString(R.string.default_account_help) + showAlert.value = true + }, + fontSize = 18.sp, + color = LocalCustomColors.current.itemText) + var defaultAccount by remember { mutableStateOf(oldDefaultAccount) } + Switch( + checked = defaultAccount, + onCheckedChange = { + defaultAccount = it + newDefaultAccount = defaultAccount + } + ) + } +} + +private fun checkOnClick(ctx: Context, ua: UserAgent): Boolean { + + val acc = ua.account + + val nn = newNickname.trim() + if (nn != oldNickname) { + if (Account.checkDisplayName(nn)) { + if (nn == "" || Account.uniqueNickName(nn)) { + acc.nickName.value = nn + Log.d(TAG, "New nickname is ${acc.nickName.value}") + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.non_unique_account_nickname), nn) + showAlert.value = true + return false + } + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_account_nickname), nn) + showAlert.value = true + return false + } + } + + val dn = newDisplayname.trim() + if (dn != acc.displayName) { + if (Account.checkDisplayName(dn)) { + if (Api.account_set_display_name(acc.accp, dn) == 0) { + acc.displayName = Api.account_display_name(acc.accp) + Log.d(TAG, "New display name is ${acc.displayName}") + } else { + Log.e(TAG, "Setting of display name failed") + } + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_display_name), dn) + showAlert.value = true + return false + } + } + + val au = newAuthUser.trim() + if (au != oldAuthUser) { + if (Account.checkAuthUser(au)) { + if (Api.account_set_auth_user(acc.accp, au) == 0) { + acc.authUser = Api.account_auth_user(acc.accp) + Log.d(TAG, "New auth user is ${acc.authUser}") + if (acc.regint > 0) + reRegister = true + } + else { + Log.e(TAG, "Setting of auth user failed") + } + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_authentication_username), au) + showAlert.value = true + return false + } + } + + val ap = newAuthPass.trim() + if (ap != "") { + if (ap != oldAuthPass) { + if (Account.checkAuthPass(ap)) { + if (Api.account_set_auth_pass(acc.accp, ap) == 0) { + acc.authPass = Api.account_auth_pass(acc.accp) + if (acc.regint > 0) + reRegister = true + } + else + Log.e(TAG, "Setting of auth pass failed") + BaresipService.aorPasswords.remove(acc.aor) + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_authentication_password), ap) + showAlert.value = true + return false + } + } + else + BaresipService.aorPasswords.remove(acc.aor) + } + else { // ap == "" + if (acc.authPass != NO_AUTH_PASS && acc.authPass != BaresipService.aorPasswords[acc.aor]) + if (Api.account_set_auth_pass(acc.accp, "") == 0) { + acc.authPass = NO_AUTH_PASS + BaresipService.aorPasswords[acc.aor] = NO_AUTH_PASS + } + } + + val ob = ArrayList() + var ob1 = newOutbound1.trim().replace(" ", "") + if (ob1 != "") { + if (!ob1.startsWith("sip:")) + ob1 = "sip:$ob1" + if (checkOutboundUri(ob1)) { + ob.add(ob1) + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_proxy_server_uri), ob1) + showAlert.value = true + return false + } + } + var ob2 = newOutbound2.trim().replace(" ", "") + if (ob2 != "") { + if (!ob2.startsWith("sip:")) + ob2 = "sip:$ob2" + if (checkOutboundUri(ob2)) + ob.add(ob2) + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_proxy_server_uri), ob2) + showAlert.value = true + return false + } + } + if (ob != acc.outbound) { + for (i in 0..1) { + val uri = if (ob.size > i) + ob[i] + else + "" + if (Api.account_set_outbound(acc.accp, uri, i) != 0) + Log.e(TAG, "Setting of outbound proxy $i uri '$uri' failed") + } + Log.d(TAG, "New outbound proxies are $ob") + acc.outbound = ob + if (ob.isEmpty()) + Api.account_set_sipnat(acc.accp, "") + else + Api.account_set_sipnat(acc.accp, "outbound") + if (acc.regint > 0) + reRegister = true + } + + val regInt = newRegInt.trim().toInt() + if (regInt < 60 || regInt > 3600) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_reg_int), "$regInt") + showAlert.value = true + return false + } + val reReg = (newRegister != acc.regint > 0) || + (newRegister && regInt != acc.configuredRegInt) + if (reReg) { + if (Api.account_set_regint(acc.accp, + if (newRegister) regInt else 0) != 0) { + Log.e(TAG, "Setting of regint failed") + } else { + acc.regint = Api.account_regint(acc.accp) + acc.configuredRegInt = regInt + Log.d(TAG, "New regint is ${acc.regint}") + reRegister = true + } + } else { + if (regInt != acc.configuredRegInt) { + acc.configuredRegInt = regInt + } + } + + if (newMediaEnc != acc.mediaEnc) { + if (Api.account_set_mediaenc(acc.accp, newMediaEnc) == 0) { + acc.mediaEnc = Api.account_mediaenc(acc.accp) + Log.d(TAG, "New mediaenc is ${acc.mediaEnc}") + } else { + Log.e(TAG, "Setting of mediaenc $newMediaEnc failed") + } + } + + if (newMediaNat != acc.mediaNat) { + if (Api.account_set_medianat(acc.accp, newMediaNat) == 0) { + acc.mediaNat = Api.account_medianat(acc.accp) + Log.d(TAG, "New medianat is ${acc.mediaNat}") + } else { + Log.e(TAG, "Setting of medianat $newMediaNat failed") + } + } + + newStunServer = newStunServer.trim() + + if (newMediaNat != "") { + if (((newMediaNat == "stun") || (newMediaNat == "ice")) && (newStunServer == "")) + newStunServer = ctx.getString(R.string.stun_server_default) + if (!Utils.checkStunUri(newStunServer) || + (newMediaNat == "turn" && + newStunServer.substringBefore(":") !in setOf("turn", "turns"))) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_stun_server), newStunServer) + showAlert.value = true + return false + } + } + + if (acc.stunServer != newStunServer) { + if (Api.account_set_stun_uri(acc.accp, newStunServer) == 0) { + acc.stunServer = Api.account_stun_uri(acc.accp) + Log.d(TAG, "New STUN/TURN server URI is '${acc.stunServer}'") + } else { + Log.e(TAG, "Setting of STUN/TURN URI server failed") + } + } + + newStunUser = newStunUser.trim() + if (acc.stunUser != newStunUser) { + if (Account.checkAuthUser(newStunUser)) { + if (Api.account_set_stun_user(acc.accp, newStunUser) == 0) { + acc.stunUser = Api.account_stun_user(acc.accp) + Log.d(TAG, "New STUN/TURN user is ${acc.stunUser}") + } + else + Log.e(TAG, "Setting of STUN/TURN user failed") + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_stun_username), newStunUser) + showAlert.value = true + return false + } + } + + val newStunPass = newStunPass.trim() + if (acc.stunPass != newStunPass) { + if (newStunPass.isEmpty() || Account.checkAuthPass(newStunPass)) { + if (Api.account_set_stun_pass(acc.accp, newStunPass) == 0) + acc.stunPass = Api.account_stun_pass(acc.accp) + else + Log.e(TAG, "Setting of stun pass failed") + } + else { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_stun_password), newStunPass) + showAlert.value = true + return false + } + } + + if (newRtcpMux != acc.rtcpMux) + if (Api.account_set_rtcp_mux(acc.accp, newRtcpMux) == 0) { + acc.rtcpMux = Api.account_rtcp_mux(acc.accp) + Log.d(TAG, "New rtcpMux is ${acc.rtcpMux}") + } else { + Log.e(TAG, "Setting of account_rtc_mux $newRtcpMux failed") + } + + if (new100Rel != (acc.rel100Mode == Api.REL100_ENABLED)) { + val mode = if (new100Rel) Api.REL100_ENABLED else Api.REL100_DISABLED + if (Api.account_set_rel100_mode(acc.accp, mode) == 0) { + acc.rel100Mode = Api.account_rel100_mode(acc.accp) + Api.ua_update_account(ua.uap) + Log.d(TAG, "New rel100Mode is ${acc.rel100Mode}") + } else { + Log.e(TAG, "Setting of account_rel100Mode failed") + } + } + + if (newDtmfMode != acc.dtmfMode) { + if (Api.account_set_dtmfmode(acc.accp, newDtmfMode) == 0) { + acc.dtmfMode = Api.account_dtmfmode(acc.accp) + Log.d(TAG, "New dtmfmode is ${acc.dtmfMode}") + } else { + Log.e(TAG, "Setting of dtmfmode $newDtmfMode failed") + } + } + + if (newAnswerMode != acc.answerMode) { + if (Api.account_set_answermode(acc.accp, newAnswerMode) == 0) { + acc.answerMode = Api.account_answermode(acc.accp) + Log.d(TAG, "New answermode is ${acc.answerMode}") + } else { + Log.e(TAG, "Setting of answermode $newAnswerMode failed") + } + } + + if (newAutoRedirect != acc.autoRedirect) { + Api.account_set_sip_autoredirect(acc.accp, newAutoRedirect) + acc.autoRedirect = newAutoRedirect + Log.d(TAG, "New autoRedirect is ${acc.autoRedirect}") + } + + newVmUri = newVmUri.trim() + if (newVmUri != acc.vmUri) { + if (newVmUri != "") { + if (!newVmUri.startsWith("sip:")) newVmUri = "sip:$newVmUri" + if (!newVmUri.contains("@")) newVmUri = "$newVmUri@${acc.host()}" + if (!Utils.checkUri(newVmUri)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), newVmUri) + showAlert.value = true + return false + } + Api.account_set_mwi(acc.accp, true) + } + else + Api.account_set_mwi(acc.accp, false) + acc.vmUri = newVmUri + Log.d(TAG, "New voicemail URI is ${acc.vmUri}") + } + + newCountryCode = newCountryCode.trim() + if (newCountryCode != acc.countryCode) { + if (newCountryCode != "" && !Utils.checkCountryCode(newCountryCode)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_country_code), newCountryCode) + showAlert.value = true + return false + } + acc.countryCode = newCountryCode + Log.d(TAG, "New country code is ${acc.countryCode}") + } + + val hostPart = newTelProvider.trim() + if (hostPart != acc.telProvider) { + if (hostPart != "" && !Utils.checkHostPortParams(hostPart)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_uri_hostpart), hostPart) + showAlert.value = true + return false + } + acc.telProvider = hostPart + Log.d(TAG, "New tel provider is ${acc.telProvider}") + } + + if (newNumericKeypad != acc.numericKeypad) { + acc.numericKeypad = newNumericKeypad + Log.d(TAG, "New numericKeyboard is ${acc.numericKeypad}") + } + + if (newDefaultAccount) ua.makeDefault() + + Account.saveAccounts() + + if (acc.authUser != "" && BaresipService.aorPasswords[acc.aor] == NO_AUTH_PASS) { + showPasswordDialog.value = true + return false + } + else + return true +} + +private fun checkOutboundUri(uri: String): Boolean { + if (!uri.startsWith("sip:")) return false + return Utils.checkHostPortParams(uri.substring(4)) +} + +private fun initAccountFromConfig(acc: Account, onConfigLoaded: () -> Unit) { + val scope = CoroutineScope(Job() + Dispatchers.Main) + scope.launch(Dispatchers.IO) { + val url = "https://${Utils.uriHostPart(acc.aor)}/baresip/account_config.xml" + val config = try { + URL(url).readText() + } catch (e: java.lang.Exception) { + Log.d(TAG, "Failed to get account configuration from network: ${e.message}") + null + } + if (config != null) { + Log.d(TAG, "Got account config '$config'") + val parserFactory: XmlPullParserFactory = XmlPullParserFactory.newInstance() + val parser: XmlPullParser = parserFactory.newPullParser() + parser.setInput(StringReader(config)) + var tag: String? + var text = "" + var event = parser.eventType + val audioCodecs = ArrayList(Api.audio_codecs().split(",")) + val videoCodecs = ArrayList(Api.video_codecs().split(",")) + + while (event != XmlPullParser.END_DOCUMENT) { + tag = parser.name + when (event) { + XmlPullParser.TEXT -> + text = parser.text + + XmlPullParser.START_TAG -> { + if (tag == "audio-codecs") + acc.audioCodec.clear() + if (tag == "video-codecs") + acc.videoCodec.clear() + } + + XmlPullParser.END_TAG -> + when (tag) { + "outbound-proxy-1" -> + if (text.isNotEmpty()) + acc.outbound.add(text) + + "outbound-proxy-2" -> + if (text.isNotEmpty()) + acc.outbound.add(text) + + "registration-interval" -> + acc.configuredRegInt = text.toInt() + + "register" -> + acc.regint = if (text == "yes") acc.configuredRegInt else 0 + + "audio-codec" -> + if (text in audioCodecs) + acc.audioCodec.add(text) + + "video-codec" -> + if (text in videoCodecs) + acc.videoCodec.add(text) + + "media-encoding" -> { + val enc = text.lowercase(Locale.ROOT) + if (enc in mediaEncMap.keys && enc.isNotEmpty()) + acc.mediaEnc = enc + } + + "media-nat" -> { + val nat = text.lowercase(Locale.ROOT) + if (nat in mediaNatMap.keys && nat.isNotEmpty()) + acc.mediaNat = nat + } + + "stun-turn-server" -> + if (text.isNotEmpty()) + acc.stunServer = text + + "rtcp-mux" -> + acc.rtcpMux = text == "yes" + + "100rel-mode" -> + acc.rel100Mode = if (text == "yes") + Api.REL100_ENABLED + else + Api.REL100_DISABLED + + "dtmf-mode" -> + if (text in arrayOf("rtp-event", "sip-info", "auto")) + acc.dtmfMode = when (text) { + "rtp-event" -> Api.DTMFMODE_RTP_EVENT + "sip-info" -> Api.DTMFMODE_SIP_INFO + else -> Api.DTMFMODE_AUTO + } + + "answer-mode" -> + if (text in arrayOf("manual", "auto")) + acc.answerMode = if (text == "manual") + Api.ANSWERMODE_MANUAL + else + Api.ANSWERMODE_AUTO + + "redirect-mode" -> + acc.autoRedirect = text == "yes" + + "voicemail-uri" -> + if (text.isNotEmpty()) + acc.vmUri = text + + "country-code" -> + acc.countryCode = text + + "tel-provider" -> + acc.telProvider = text + } + } + event = parser.next() + } + } + onConfigLoaded() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt deleted file mode 100644 index c1659734..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AccountsActivity.kt +++ /dev/null @@ -1,403 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.SystemClock -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -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.Delete -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.Surface -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.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.layout.ContentScale -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -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 com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.verticalScrollbar - -class AccountsActivity : ComponentActivity() { - - internal lateinit var aor: String - private lateinit var mediaEncMap: Map - private lateinit var mediaNatMap: Map - - private var showAccounts = mutableStateOf(true) - private var lastClick: Long = 0 - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - public override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - val title = getString(R.string.accounts) - - aor = intent.getStringExtra("aor")!! - Utils.addActivity("accounts,$aor") - - mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF", - "srtp-mand" to "SRTP-MAND", "srtp" to "SRTP", "" to "--") - - mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--") - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - AccountsScreen(this, title) { goBack() } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun AccountsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier.fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text(text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - ) - }, - bottomBar = { NewAccount(ctx) }, - content = { contentPadding -> - AccountsContent(ctx, contentPadding) - }, - ) - } - - @Composable - fun AccountsContent(ctx: Context, contentPadding: PaddingValues) { - - val showDialog = remember { mutableStateOf(false) } - val message = remember { mutableStateOf("") } - val positiveAction = remember { mutableStateOf({}) } - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = message.value, - positiveButtonText = stringResource(R.string.delete), - onPositiveClicked = positiveAction.value, - negativeButtonText = stringResource(R.string.cancel), - ) - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - - if (showAccounts.value && BaresipService.uas.value.isNotEmpty()) { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .padding(contentPadding) - .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) - .verticalScrollbar(scrollState) - .verticalScroll(state = scrollState), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - for (ua in BaresipService.uas.value) { - val account = ua.account - val aor = account.aor - val text = if (account.nickName.value != "") - account.nickName.value - else - account.aor.substringAfter(":") - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = text, - fontSize = 20.sp, - color = LocalCustomColors.current.itemText, - modifier = Modifier.weight(1f).padding(start = 10.dp) - .clickable { - val i = Intent(ctx, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - i.putExtras(b) - startActivity(i) - } - ) - SmallFloatingActionButton( - onClick = { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - message.value = String.format( - ctx.getString(R.string.delete_account), - text - ) - positiveAction.value = { - CallHistoryNew.clear(aor) - Message.clearMessagesOfAor(aor) - ua.remove() - Api.ua_destroy(ua.uap) - saveAccounts() - showAccounts.value = false - showAccounts.value = true - } - showDialog.value = true - } - }, - containerColor = LocalCustomColors.current.background, - contentColor = LocalCustomColors.current.secondary - ) { - Icon( - Icons.Filled.Delete, - contentDescription = stringResource(R.string.delete) - ) - } - } - } - } - } - } - - @Composable - fun NewAccount(ctx: Context) { - var newAor by remember { mutableStateOf("") } - val focusManager = LocalFocusManager.current - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - value = newAor, - placeholder = { Text(text = stringResource(R.string.new_account)) }, - onValueChange = { newAor = it }, - modifier = Modifier - .weight(1f) - .padding(end = 8.dp) - .verticalScroll(rememberScrollState()) - .clickable { - alertTitle.value = getString(R.string.new_account) - alertMessage.value = getString(R.string.accounts_help) - showAlert.value = true - }, - singleLine = false, - trailingIcon = { - if (newAor.isNotEmpty()) { - Icon( - Icons.Outlined.Clear, - contentDescription = "Clear", - modifier = Modifier.clickable { newAor = "" } - ) - } - }, - label = { LabelText(stringResource(R.string.new_account)) }, - textStyle = TextStyle(fontSize = 18.sp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - ) - ) - Image( - painter = painterResource(id = R.drawable.plus), - contentDescription = "Add", - contentScale = ContentScale.Crop, - modifier = Modifier.size(48.dp) - .clickable( - onClick = { - val account = createNew(newAor.trim()) - if (account != null) { - val i = Intent(ctx, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", account.aor) - b.putString("kind", "new") - i.putExtras(b) - startActivity(i) - newAor = "" - focusManager.clearFocus() - showAccounts.value = false - } - } - ), - ) - } - } - - private fun createNew(newAor: String): Account? { - - val aor = if (newAor.startsWith("sip:")) - newAor - else - "sip:$newAor" - - if (!Utils.checkAor(aor)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_aor), aor.split(":")[1]) - showAlert.value = true - return null - } - - if (Account.ofAor(aor) != null) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.account_exists), aor.split(":")[1]) - showAlert.value = true - return null - } - - val ua = UserAgent.uaAlloc( - "<$aor>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;mwi=no" - ) - if (ua == null) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = getString(R.string.account_allocation_failure) - showAlert.value = true - return null - } - - // Api.account_debug(ua.account.accp) - val acc = ua.account - Log.d(TAG, "Allocated UA ${ua.uap} with SIP URI ${acc.luri}") - saveAccounts() - - return acc - } - - private fun goBack() { - BaresipService.activities.remove("accounts,$aor") - setResult(RESULT_CANCELED, Intent()) - finish() - } - - override fun onPause() { - MainActivity.activityAor = aor - super.onPause() - } - - override fun onResume() { - super.onResume() - showAccounts.value = true - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - companion object { - - fun saveAccounts() { - var accounts = "" - for (a in Account.accounts()) accounts = accounts + a.print() + "\n" - Utils.putFileContents(BaresipService.filesPath + "/accounts", - accounts.toByteArray(Charsets.UTF_8)) - // Log.d(TAG, "Saved accounts '${accounts}' to '${BaresipService.filesPath}/accounts'") - } - - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AccountsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/AccountsScreen.kt new file mode 100644 index 00000000..e3329f1e --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/AccountsScreen.kt @@ -0,0 +1,306 @@ +package com.tutpro.baresip + +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +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.Delete +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +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.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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +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.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.verticalScrollbar + +fun NavGraphBuilder.accountsScreenRoute(navController: NavController) { + composable("accounts") { + AccountsScreen(navController) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen(navController: NavController) { + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.accounts), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = LocalCustomColors.current.light + ) + } + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0) + ) + } + }, + bottomBar = { NewAccount(navController) }, + content = { contentPadding -> + AccountsContent(contentPadding, navController) + }, + ) +} + + +@Composable +fun AccountsContent(contentPadding: PaddingValues, navController: NavController) { + + val ctx = LocalContext.current + + val showDialog = remember { mutableStateOf(false) } + val message = remember { mutableStateOf("") } + val positiveAction = remember { mutableStateOf({}) } + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = message.value, + positiveButtonText = stringResource(R.string.delete), + onPositiveClicked = positiveAction.value, + negativeButtonText = stringResource(R.string.cancel), + ) + + val showAccounts = remember { mutableStateOf(true) } + + if (showAccounts.value && BaresipService.uas.value.isNotEmpty()) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .verticalScrollbar(scrollState) + .verticalScroll(state = scrollState), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (ua in BaresipService.uas.value) { + val account = ua.account + val aor = account.aor + val text = if (account.nickName.value != "") + account.nickName.value + else + account.aor.substringAfter(":") + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + fontSize = 20.sp, + color = LocalCustomColors.current.itemText, + modifier = Modifier.weight(1f).padding(start = 10.dp) + .clickable { + navController.navigate("account/$aor/old") + } + ) + SmallFloatingActionButton( + onClick = { + message.value = String.format( + ctx.getString(R.string.delete_account), + text + ) + positiveAction.value = { + CallHistoryNew.clear(aor) + Message.clearMessagesOfAor(aor) + ua.remove() + Api.ua_destroy(ua.uap) + Account.saveAccounts() + showAccounts.value = false + showAccounts.value = true + } + showDialog.value = true + }, + containerColor = LocalCustomColors.current.background, + contentColor = LocalCustomColors.current.secondary + ) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete) + ) + } + } + } + } + } +} + +@Composable +fun NewAccount(navController: NavController) { + + val alertTitle = remember { mutableStateOf("") } + val alertMessage = remember { mutableStateOf("") } + val showAlert = remember { mutableStateOf(false) } + + if (showAlert.value) + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + + fun createNew(ctx: Context, newAor: String): Account? { + + val aor = if (newAor.startsWith("sip:")) + newAor + else + "sip:$newAor" + + if (!Utils.checkAor(aor)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = + String.format(ctx.getString(R.string.invalid_aor), aor.split(":")[1]) + showAlert.value = true + return null + } + + if (Account.ofAor(aor) != null) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = + String.format(ctx.getString(R.string.account_exists), aor.split(":")[1]) + showAlert.value = true + return null + } + + val ua = UserAgent.uaAlloc( + "<$aor>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;mwi=no" + ) + if (ua == null) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.account_allocation_failure) + showAlert.value = true + return null + } + + // Api.account_debug(ua.account.accp) + val acc = ua.account + Log.d(TAG, "Allocated UA ${ua.uap} with SIP URI ${acc.luri}") + Account.saveAccounts() + return acc + } // createNew + + var newAor by remember { mutableStateOf("") } + val focusManager = LocalFocusManager.current + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val ctx = LocalContext.current + OutlinedTextField( + value = newAor, + placeholder = { Text(text = stringResource(R.string.new_account)) }, + onValueChange = { newAor = it }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + .verticalScroll(rememberScrollState()) + .clickable { + alertTitle.value = ctx.getString(R.string.new_account) + alertMessage.value = ctx.getString(R.string.accounts_help) + showAlert.value = true + }, + singleLine = false, + trailingIcon = { + if (newAor.isNotEmpty()) { + Icon( + Icons.Outlined.Clear, + contentDescription = "Clear", + modifier = Modifier.clickable { newAor = "" } + ) + } + }, + label = { LabelText(stringResource(R.string.new_account)) }, + textStyle = TextStyle(fontSize = 18.sp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + ) + ) + Image( + painter = painterResource(id = R.drawable.plus), + contentDescription = "Add", + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp) + .clickable( + onClick = { + val account = createNew(ctx, newAor.trim()) + if (account != null) { + navController.navigate("account/${account.aor}/new") + newAor = "" + focusManager.clearFocus() + } + } + ) + ) + } +} + diff --git a/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt deleted file mode 100644 index dc792e09..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AndroidContactActivity.kt +++ /dev/null @@ -1,305 +0,0 @@ -package com.tutpro.baresip - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image -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.fillMaxHeight -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.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage - -class AndroidContactActivity : ComponentActivity() { - - private lateinit var aor: String - private lateinit var name: String - - private var color = 0 - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - aor = intent.getStringExtra("aor")!! - name = intent.getStringExtra("name")!! - - val title: String = name - val contact = Contact.androidContact(name) - - if (contact == null) { - Log.e(TAG, "No Android contact found with name $name") - goBack() - } - - Utils.addActivity("android contact, $name") - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - ContactScreen(this, title, contact!!) { goBack() } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun ContactScreen(ctx: Context, title: String, contact: Contact.AndroidContact, - navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier.fillMaxHeight().imePadding().safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text( - text = title, color = LocalCustomColors.current.grayLight, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - ) - }, - content = { contentPadding -> - ContactContent(ctx, contentPadding, contact) - } - ) - } - - @Composable - fun ContactContent(ctx: Context, contentPadding: PaddingValues, contact: Contact.AndroidContact) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(LocalCustomColors.current.background) - .padding(contentPadding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Avatar(contact) - ContactName() - Uris(ctx, contact) - } - } - - @Composable - fun TextAvatar(text: String, size: Int, color: Int) { - Box( - modifier = Modifier.size(size.dp), - contentAlignment = Alignment.Center - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawCircle(SolidColor(Color(color))) - } - Text(text, fontSize = 72.sp, color = Color.White) - } - } - - @Composable - fun ImageAvatar(uri: Uri, size: Int) { - AsyncImage( - model = uri, - contentDescription = stringResource(R.string.avatar_image), - contentScale = ContentScale.Crop, - modifier = Modifier.size(size.dp).clip(CircleShape) - ) - } - - @Composable - fun Avatar(contact: Contact.AndroidContact) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - color = contact.color - val thumbnailUri = contact.thumbnailUri - if (thumbnailUri != null) - ImageAvatar(thumbnailUri, 96) - else - TextAvatar(if (name == "") "" else name[0].toString(), 96, color) - } - } - - @Composable - fun ContactName() { - Row( - Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(name, fontSize = 24.sp, color = LocalCustomColors.current.itemText) - } - } - - @Composable - fun Uris(ctx: Context, contact: Contact.AndroidContact) { - val lazyListState = rememberLazyListState() - LazyColumn( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp) - .background(LocalCustomColors.current.background), - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(contact.uris) { uri -> - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = uri.substringAfter(":"), - modifier = Modifier.weight(1f), - fontSize = 18.sp, - color = LocalCustomColors.current.itemText, - ) - Image( - painter = painterResource(R.drawable.message), - colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText), - contentDescription = "Send Message", - modifier = Modifier.padding(end = 24.dp).clickable { - val i = Intent(ctx, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "message") - val ua = UserAgent.ofAor(aor) - if (ua == null) { - Log.w(TAG, "message clickable did not find AoR $aor") - } else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", uri) - (ctx as Activity).startActivity(i) - } - } - ) - Image( - painter = painterResource(R.drawable.call_small), - colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText), - contentDescription = "Call", - modifier = Modifier.clickable { - val i = Intent(ctx, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "call") - val ua = UserAgent.ofAor(aor) - if (ua == null) { - Log.w(TAG, "call clickable did not find AoR $aor") - } else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", uri) - (ctx as Activity).startActivity(i) - } - } - ) - } - } - } - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("android contact,$name") - setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java)) - finish() - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AndroidContactScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/AndroidContactScreen.kt new file mode 100644 index 00000000..aa3d4176 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/AndroidContactScreen.kt @@ -0,0 +1,272 @@ +package com.tutpro.baresip + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import coil.compose.AsyncImage + +fun NavGraphBuilder.androidContactScreenRoute(navController: NavController, viewModel: ViewModel) { + composable( + route = "android_contact/{name}", + arguments = listOf(navArgument("name") { type = NavType.StringType }) + ) { backStackEntry -> + val ctx = LocalContext.current + val name = backStackEntry.arguments?.getString("name")!! + ContactScreen( + ctx = ctx, + viewModel = viewModel, + navController = navController, + name = name + ) + } +} + +@Composable +private fun ContactScreen(ctx: Context, viewModel: ViewModel, navController: NavController, name: String) { + val contact = Contact.androidContact(name) + if (contact == null) { + Log.e(TAG, "No Android contact found with name $name") + navController.popBackStack() + } + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar(name, navController) + } + }, + content = { contentPadding -> + ContactContent(ctx, viewModel, navController, contentPadding, contact!!) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar(title: String, navController: NavController) { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + } + ) +} + +@Composable +private fun ContactContent( + ctx: Context, + viewModel: ViewModel, + navController: NavController, + contentPadding: PaddingValues, + contact: Contact.AndroidContact +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(contact) + ContactName(contact.name) + Uris(ctx, viewModel, navController, contact) + } +} + +@Composable +private fun TextAvatar(text: String, color: Int) { + Box( + modifier = Modifier.size(avatarSize.dp), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(SolidColor(Color(color))) + } + Text(text, fontSize = 72.sp, color = Color.White) + } +} + +@Composable +private fun ImageAvatar(uri: Uri) { + AsyncImage( + model = uri, + contentDescription = stringResource(R.string.avatar_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(avatarSize.dp).clip(CircleShape) + ) +} + +@Composable +private fun Avatar(contact: Contact.AndroidContact) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + val color = contact.color + val name = contact.name + val thumbnailUri = contact.thumbnailUri + if (thumbnailUri != null) + ImageAvatar(thumbnailUri) + else + TextAvatar(if (name == "") "" else name[0].toString(), color) + } +} + +@Composable +private fun ContactName(name: String) { + Row( + Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text(name, fontSize = 24.sp, color = LocalCustomColors.current.itemText) + } +} + +@Composable +private fun Uris( + ctx: Context, + viewModel: ViewModel, + navController: NavController, + contact: Contact.AndroidContact +) { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(contact.uris) { uri -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = uri.substringAfter(":"), + modifier = Modifier.weight(1f), + fontSize = 18.sp, + color = LocalCustomColors.current.itemText, + ) + Image( + painter = painterResource(R.drawable.message), + colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText), + contentDescription = "Send Message", + modifier = Modifier.padding(end = 24.dp).clickable { + val aor = viewModel.selectedAor.value + val ua = UserAgent.ofAor(aor) + if (ua == null) + Log.w(TAG, "message clickable did not find AoR $aor") + else { + val intent = Intent(ctx, MainActivity::class.java) + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", uri) + handleIntent(ctx, viewModel, intent, "message") + navController.navigate("main") { + popUpTo("main") { inclusive = false } + launchSingleTop = true + } + } + } + ) + Image( + painter = painterResource(R.drawable.call_small), + colorFilter = ColorFilter.tint(LocalCustomColors.current.itemText), + contentDescription = "Call", + modifier = Modifier.clickable { + val aor = viewModel.selectedAor.value + val ua = UserAgent.ofAor(aor) + if (ua == null) + Log.w(TAG, "message clickable did not find AoR $aor") + else { + val intent = Intent(ctx, MainActivity::class.java) + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", uri) + handleIntent(ctx, viewModel, intent, "call") + navController.navigate("main") { + popUpTo("main") { inclusive = false } + launchSingleTop = true + } + } + } + ) + } + } + } +} + + diff --git a/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt b/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt index 10515dde..97e1a1f9 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/AppTheme.kt @@ -2,6 +2,7 @@ package com.tutpro.baresip import android.app.Activity import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -10,59 +11,61 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.core.graphics.ColorUtils import androidx.core.view.WindowCompat @Composable fun AppTheme( content: @Composable () -> Unit ) { - val darkTheme = remember { BaresipService.darkTheme } + val useDarkTheme by remember { BaresipService.darkTheme } - // "normal" palette, nothing change here val colorScheme = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (darkTheme.value) dynamicDarkColorScheme(context = LocalContext.current) - else dynamicLightColorScheme(context = LocalContext.current) + if (useDarkTheme) + dynamicDarkColorScheme(context = LocalContext.current) + else + if (isSystemInDarkTheme()) + dynamicDarkColorScheme(context = LocalContext.current) + else + dynamicLightColorScheme(context = LocalContext.current) } - darkTheme.value -> darkColorScheme() - else -> lightColorScheme() + useDarkTheme -> darkColorScheme() + else -> + if (isSystemInDarkTheme()) + darkColorScheme() + else + lightColorScheme() } - // logic for which custom palette to use val customColorsPalette = - if (darkTheme.value) DarkCustomColors + if (useDarkTheme) DarkCustomColors else LightCustomColors val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - val decorView = window.decorView + val insetsController = WindowCompat.getInsetsController(window, view) - // Ensure insets are applied correctly - WindowCompat.setDecorFitsSystemWindows(window, false) + // A common threshold for luminance is 0.5. Colors with luminance > 0.5 are considered light. + val isBackgroundEffectivelyLight = ColorUtils.calculateLuminance(customColorsPalette.background.toArgb()) > 0.5 - // Handle the status bar appearance - val insetsController = WindowCompat.getInsetsController(window, decorView) - window.statusBarColor = customColorsPalette.background.toArgb() - window.navigationBarColor = customColorsPalette.background.toArgb() - insetsController.apply { - isAppearanceLightStatusBars = !darkTheme.value - isAppearanceLightNavigationBars = !darkTheme.value - } + insetsController.isAppearanceLightStatusBars = isBackgroundEffectivelyLight + insetsController.isAppearanceLightNavigationBars = isBackgroundEffectivelyLight } } - // here is the important point, where you will expose custom objects CompositionLocalProvider( - LocalCustomColors provides customColorsPalette // our custom palette + LocalCustomColors provides customColorsPalette ) { MaterialTheme( - colorScheme = colorScheme, // the MaterialTheme still uses the "normal" palette + colorScheme = colorScheme, // MaterialTheme still uses the "normal" colorScheme content = content ) } diff --git a/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt deleted file mode 100644 index ffb27ef2..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/AudioActivity.kt +++ /dev/null @@ -1,700 +0,0 @@ -package com.tutpro.baresip - -import android.os.Build -import android.os.Bundle -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -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.fillMaxHeight -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.safeDrawingPadding -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.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.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -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.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 com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.verticalScrollbar - -class AudioActivity : ComponentActivity() { - - private var save = false - private var restart = false - private var oldCallVolume = BaresipService.callVolume - private var newCallVolume = oldCallVolume - private var oldMicGain = "" - private var newMicGain = "" - private var newSpeakerPhone = BaresipService.speakerPhone - private val modules = Config.variables("module") - private var newAudioModules = mutableMapOf() - private var oldOpusBitrate = Config.variable("opus_bitrate") - private var newOpusBitrate = oldOpusBitrate - private var oldOpusPacketLoss = Config.variable("opus_packet_loss") - private var newOpusPacketLoss = oldOpusPacketLoss - private var newAudioDelay = BaresipService.audioDelay.toString() - private var newToneCountry = BaresipService.toneCountry - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - Utils.addActivity("audio") - - if (!BaresipService.agcAvailable) - oldMicGain = Config.variable("augain") - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - AudioScreen { goBack() } - } - } - } - } - - @Composable - fun AudioScreen(navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(navigateBack) }, - content = { contentPadding -> - AudioContent(contentPadding) - } - ) - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(navigateBack: () -> Unit) { - androidx.compose.material3.TopAppBar( - title = { - Text( - text = stringResource(R.string.audio_settings), - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton(onClick = { - checkOnClick() - }) { - Icon( - imageVector = Icons.Filled.Check, - tint = LocalCustomColors.current.light, - contentDescription = "Check" - ) - } - } - ) - } - - @Composable - fun AudioContent(contentPadding: PaddingValues) { - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .imePadding() - .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), - ) { - CallVolume() - MicGain() - SpeakerPhone() - AudioModules() - OpusBitRate() - OpusPacketLoss() - AudioDelay() - ToneCountry() - } - } - - @Composable - private fun CallVolume() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.default_call_volume), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.default_call_volume) - alertMessage.value = getString(R.string.default_call_volume_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - 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 { - mutableIntStateOf(volValues.indexOf(oldCallVolume)) - } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = volNames[itemPosition.intValue], - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - 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, - color = LocalCustomColors.current.itemText - ) - } - } - } - } - } - - @Composable - private fun MicGain() { - if (!BaresipService.agcAvailable) - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var micGain by remember { mutableStateOf(oldMicGain) } - newMicGain = micGain - OutlinedTextField( - value = micGain, - placeholder = { Text(stringResource(R.string.microphone_gain)) }, - onValueChange = { - micGain = it - newMicGain = micGain - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.microphone_gain) - alertMessage.value = getString(R.string.microphone_gain_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText - ), - label = { LabelText(stringResource(R.string.microphone_gain)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun SpeakerPhone() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.speaker_phone), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.speaker_phone) - alertMessage.value = getString(R.string.speaker_phone_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var speakerPhone by remember { mutableStateOf(BaresipService.speakerPhone) } - Switch( - checked = speakerPhone, - onCheckedChange = { - speakerPhone = it - newSpeakerPhone = speakerPhone - } - ) - } - } - - @Composable - private fun AudioModules() { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start, - ) { - Text(text = stringResource(R.string.audio_modules_title), - color = LocalCustomColors.current.itemText, - fontSize = 18.sp, - modifier = Modifier.clickable { - alertTitle.value = getString(R.string.audio_modules_title) - alertMessage.value = getString(R.string.audio_modules_help) - showAlert.value = true - }) - for (module in audioModules) { - Row(horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 18.dp, end = 10.dp) - ) { - Text(text = String.format(getString(R.string.bullet_item), module), - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - Spacer(modifier = Modifier.weight(1f)) - var checked by remember { mutableStateOf(modules.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 - ) { - var opusBitrate by remember { mutableStateOf(oldOpusBitrate) } - newOpusBitrate = opusBitrate - OutlinedTextField( - value = opusBitrate, - placeholder = { Text(stringResource(R.string.opus_bit_rate)) }, - onValueChange = { - opusBitrate = it - newOpusBitrate = opusBitrate - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.opus_bit_rate) - alertMessage.value = getString(R.string.opus_bit_rate_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.opus_bit_rate)) }, - 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 - ) { - var opusPacketLoss by remember { mutableStateOf(oldOpusPacketLoss) } - newOpusPacketLoss = opusPacketLoss - OutlinedTextField( - value = opusPacketLoss, - placeholder = { Text(stringResource(R.string.opus_packet_loss)) }, - onValueChange = { - opusPacketLoss = it - newOpusPacketLoss = opusPacketLoss - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.opus_packet_loss) - alertMessage.value = getString(R.string.opus_packet_loss_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.opus_packet_loss)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun AudioDelay() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var audioDelay by remember { mutableStateOf(BaresipService.audioDelay.toString()) } - newAudioDelay = audioDelay - OutlinedTextField( - value = audioDelay, - placeholder = { Text(getString(R.string.audio_delay)) }, - onValueChange = { - audioDelay = it - newAudioDelay = audioDelay - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.audio_delay) - alertMessage.value = getString(R.string.audio_delay_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.audio_delay)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun ToneCountry() { - Row( - Modifier.fillMaxWidth().padding(end=10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.tone_country), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.tone_country) - alertMessage.value = getString(R.string.tone_country_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - 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 { - mutableIntStateOf(countryValues.indexOf(BaresipService.toneCountry)) - } - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = countryNames[itemPosition.intValue], - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - 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, - color = LocalCustomColors.current.itemText - ) - } - } - } - } - } - - private fun checkOnClick() { - - if (BaresipService.activities.indexOf("audio") == -1) - return - - 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 = getString(R.string.notice) - alertMessage.value = "${getString(R.string.invalid_microphone_gain)}: $gain." - showAlert.value = true - return - } - 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 = getString(R.string.error) - alertMessage.value = getString(R.string.failed_to_load_module) + ": augain.so" - showAlert.value = true - return - } - Config.addVariable("module", "augain.so") - } - Config.replaceVariable("augain", gain) - Api.cmd_exec("augain $gain") - } - save = true - } - } - - if (newSpeakerPhone != BaresipService.speakerPhone) { - BaresipService.speakerPhone = newSpeakerPhone - Config.replaceVariable("speaker_phone", - if (BaresipService.speakerPhone) "yes" else "no") - save = true - } - - for (module in audioModules) { - if (newAudioModules[module] != null) { - if (newAudioModules[module]!!) { - if (!modules.contains("${module}.so")) { - if (Api.module_load("${module}.so") != 0) { - alertTitle.value = getString(R.string.error) - alertMessage.value = "${getString(R.string.failed_to_load_module)}: ${module}.so" - showAlert.value = true - return - } - Config.addVariable("module", "${module}.so") - save = true - } - } else if (modules.contains("${module}.so")) { - Api.module_unload("${module}.so") - Config.removeVariableValue("module", "${module}.so") - for (ua in BaresipService.uas.value) - ua.account.removeAudioCodecs(module) - AccountsActivity.saveAccounts() - save = true - } - } - } - - if (newOpusBitrate != oldOpusBitrate) { - if (!checkOpusBitRate(newOpusBitrate)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = "${getString(R.string.invalid_opus_bitrate)}: $newOpusBitrate." - showAlert.value = true - return - } - Config.replaceVariable("opus_bitrate", newOpusBitrate) - restart = true - save = true - } - - if (newOpusPacketLoss != oldOpusPacketLoss) { - if (!checkOpusPacketLoss(newOpusPacketLoss)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = "${getString(R.string.invalid_opus_packet_loss)}: $newOpusPacketLoss" - showAlert.value = true - return - } - Config.replaceVariable("opus_packet_loss", newOpusPacketLoss) - restart = true - save = true - } - - val audioDelay = newAudioDelay.trim() - if (audioDelay != BaresipService.audioDelay.toString()) { - if (!checkAudioDelay(audioDelay)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_audio_delay), audioDelay) - showAlert.value = true - return - } - Config.replaceVariable("audio_delay", audioDelay) - BaresipService.audioDelay = audioDelay.toLong() - save = true - } - - if (BaresipService.toneCountry != newToneCountry) { - BaresipService.toneCountry = newToneCountry - Config.replaceVariable("tone_country", newToneCountry) - save = true - } - - if (save) - Config.save() - - setResult(if (restart) RESULT_OK else RESULT_CANCELED) - - BaresipService.activities.remove("audio") - finish() - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("audio") - finish() - } - - 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) - } - - companion object { - val audioModules = listOf("opus", "amr", "g722", "g7221", "g726", "g729", "codec2", "g711") - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/AudioScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/AudioScreen.kt new file mode 100644 index 00000000..8bd2610c --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/AudioScreen.kt @@ -0,0 +1,664 @@ +package com.tutpro.baresip + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +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.asPaddingValues +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.statusBars +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.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.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.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.verticalScrollbar + +fun NavGraphBuilder.audioScreenRoute( + navController: NavController, +) { + composable("audio") { + val activity = LocalActivity.current + val viewModel: ViewModel = viewModel(activity as ComponentActivity) + val ctx = LocalContext.current + AudioScreen( + onBack = { navController.popBackStack() }, + checkOnClick = { + viewModel.setAudioSettingsResult(checkOnClick(ctx)) + navController.popBackStack() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AudioScreen( + onBack: () -> Unit, + checkOnClick: () -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.audio_settings), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = LocalCustomColors.current.light + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + actions = { + IconButton(onClick = checkOnClick) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ) + ) + } + } + ) { contentPadding -> + AudioContent(contentPadding) + } +} + +private var oldCallVolume = BaresipService.callVolume +private var newCallVolume = oldCallVolume +private var oldMicGain = "" +private var newMicGain = "" +private var oldSpeakerPhone = BaresipService.speakerPhone +private var newSpeakerPhone = oldSpeakerPhone +private var oldAudioModules = ArrayList() +private var newAudioModules = mutableMapOf() +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 save = false + +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + +@Composable +private fun AudioContent(contentPadding: PaddingValues) { + + oldAudioModules = Config.variables("module") + 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, + positiveButtonText = 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), + ) { + CallVolume() + MicGain() + SpeakerPhone() + AudioModules() + OpusBitRate() + OpusPacketLoss() + AudioDelay() + ToneCountry() + } +} + +@Composable +private fun CallVolume() { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.default_call_volume), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.default_call_volume) + alertMessage.value = ctx.getString(R.string.default_call_volume_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + 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 { + mutableIntStateOf(volValues.indexOf(oldCallVolume)) + } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = volNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + 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, + color = LocalCustomColors.current.itemText + ) + } + } + } + } +} + +@Composable +private fun MicGain() { + if (!BaresipService.agcAvailable) + Row( + Modifier.fillMaxWidth().padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var micGain by remember { mutableStateOf(oldMicGain) } + newMicGain = micGain + OutlinedTextField( + value = micGain, + placeholder = { Text(stringResource(R.string.microphone_gain)) }, + onValueChange = { + micGain = it + newMicGain = micGain + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.microphone_gain) + alertMessage.value = ctx.getString(R.string.microphone_gain_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { LabelText(stringResource(R.string.microphone_gain)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun SpeakerPhone() { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.speaker_phone), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.speaker_phone) + alertMessage.value = ctx.getString(R.string.speaker_phone_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var speakerPhone by remember { mutableStateOf(BaresipService.speakerPhone) } + Switch( + checked = speakerPhone, + onCheckedChange = { + speakerPhone = it + newSpeakerPhone = speakerPhone + } + ) + } +} + +@Composable +private fun AudioModules() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.audio_modules_title), + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + modifier = Modifier.clickable { + alertTitle.value = ctx.getString(R.string.audio_modules_title) + alertMessage.value = ctx.getString(R.string.audio_modules_help) + 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(ctx.getString(R.string.bullet_item), module), + color = LocalCustomColors.current.itemText, + 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 ctx = LocalContext.current + var opusBitrate by remember { mutableStateOf(oldOpusBitrate) } + newOpusBitrate = opusBitrate + OutlinedTextField( + value = opusBitrate, + placeholder = { Text(stringResource(R.string.opus_bit_rate)) }, + onValueChange = { + opusBitrate = it + newOpusBitrate = opusBitrate + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.opus_bit_rate) + alertMessage.value = ctx.getString(R.string.opus_bit_rate_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.opus_bit_rate)) }, + 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 ctx = LocalContext.current + var opusPacketLoss by remember { mutableStateOf(oldOpusPacketLoss) } + newOpusPacketLoss = opusPacketLoss + OutlinedTextField( + value = opusPacketLoss, + placeholder = { Text(stringResource(R.string.opus_packet_loss)) }, + onValueChange = { + opusPacketLoss = it + newOpusPacketLoss = opusPacketLoss + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.opus_packet_loss) + alertMessage.value = ctx.getString(R.string.opus_packet_loss_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.opus_packet_loss)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun AudioDelay() { + Row( + Modifier.fillMaxWidth().padding(end = 10.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var audioDelay by remember { mutableStateOf(BaresipService.audioDelay.toString()) } + newAudioDelay = audioDelay + OutlinedTextField( + value = audioDelay, + placeholder = { Text(stringResource(R.string.audio_delay)) }, + onValueChange = { + audioDelay = it + newAudioDelay = audioDelay + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.audio_delay) + alertMessage.value = ctx.getString(R.string.audio_delay_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.audio_delay)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun ToneCountry() { + Row( + Modifier.fillMaxWidth().padding(end=10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.tone_country), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.tone_country) + alertMessage.value = ctx.getString(R.string.tone_country_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + 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 { + mutableIntStateOf(countryValues.indexOf(BaresipService.toneCountry)) + } + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = countryNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + 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, + color = LocalCustomColors.current.itemText + ) + } + } + } + } +} + +private fun checkOnClick(ctx: Context): Boolean { + + var restart = false + + 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 false + } + 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 false + } + Config.addVariable("module", "augain.so") + } + Config.replaceVariable("augain", gain) + Api.cmd_exec("augain $gain") + } + save = true + } + } + + if (newSpeakerPhone != BaresipService.speakerPhone) { + BaresipService.speakerPhone = newSpeakerPhone + Config.replaceVariable("speaker_phone", + if (BaresipService.speakerPhone) "yes" else "no") + 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 false + } + 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 false + } + 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 false + } + 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 false + } + Config.replaceVariable("audio_delay", audioDelay) + BaresipService.audioDelay = audioDelay.toLong() + save = true + } + + if (BaresipService.toneCountry != newToneCountry) { + BaresipService.toneCountry = newToneCountry + Config.replaceVariable("tone_country", newToneCountry) + save = true + } + + if (save) Config.save() + + return restart +} + +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) +} + diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt deleted file mode 100644 index 78071dc8..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactActivity.kt +++ /dev/null @@ -1,724 +0,0 @@ -package com.tutpro.baresip - -import android.content.ContentProviderOperation -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.database.Cursor -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.provider.ContactsContract -import android.provider.ContactsContract.CommonDataKinds -import android.provider.ContactsContract.Contacts.Data -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -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.fillMaxHeight -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.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -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.LaunchedEffect -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.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.graphics.scale -import androidx.exifinterface.media.ExifInterface -import coil.compose.rememberAsyncImagePainter -import com.tutpro.baresip.CustomElements.AlertDialog -import java.io.ByteArrayOutputStream -import java.io.File - -class BaresipContactActivity : ComponentActivity() { - - private lateinit var name: String - private lateinit var uri: String - private var new = false - private var favorite = false - private var android = false - private var newName = "" - private var newUri = "" - private var newFavorite = false - private var newAndroid = false - private var uriOrName = "" - private var color = 0 - private var id: Long = 0 - private var newId: Long = 0 - private var tmpFile: File? = null - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private val textAvatarText = mutableStateOf("") - private val textAvatarColor = mutableIntStateOf(0) - private val imageAvatarUri = mutableStateOf("") - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - public override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - val title: String - - new = intent.getBooleanExtra("new", false) - - if (new) { - name = "" - uri = intent.getStringExtra("uri")!! - favorite = false - android = BaresipService.contactsMode == "android" - title = getString(R.string.new_contact) - uriOrName = uri - color = Utils.randomColor() - id = System.currentTimeMillis() - newId = id - } - else { - name = intent.getStringExtra("name")!! - val contact = Contact.baresipContact(name)!! - uri = contact.uri - favorite = contact.favorite - android = false - title = name - uriOrName = name - color = contact.color - id = contact.id - newId = id - if (contact.avatarImage != null) { - val avatarFile = File(BaresipService.filesPath, "${newId}.png") - if (avatarFile.exists()) - imageAvatarUri.value = Uri.fromFile(avatarFile).toString() - } - } - - Utils.addActivity("baresip contact,$new,$uriOrName") - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - ContactScreen(this, title) { goBack() } - } - } - } - } - - @Composable - fun ContactScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier.fillMaxHeight().imePadding().safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(ctx, title, navigateBack) }, - content = { contentPadding -> - ContactContent(contentPadding) - } - ) - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(ctx: Context, title: String, navigateBack: () -> Unit) { - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton(onClick = { - checkOnClick(ctx) - }) { - Icon( - imageVector = Icons.Filled.Check, - tint = LocalCustomColors.current.light, - contentDescription = "Check" - ) - } - } - ) - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun ContactContent(contentPadding: PaddingValues) { - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .background(LocalCustomColors.current.background) - .padding(contentPadding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Avatar() - ContactName() - ContactUri() - Favorite() - Android() - } - } - - - @Composable - fun TextAvatar(size: Int) { - Box( - modifier = Modifier.size(size.dp), - contentAlignment = Alignment.Center - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawCircle(SolidColor(Color(textAvatarColor.intValue))) - } - Text(textAvatarText.value, fontSize = 72.sp, color = Color.White) - } - } - - @Composable - fun ImageAvatar(size: Int) { - Image( - painter = rememberAsyncImagePainter(model = imageAvatarUri.value), - contentDescription = stringResource(R.string.avatar_image), - contentScale = ContentScale.Crop, - modifier = Modifier.size(size.dp).clip(CircleShape) - ) - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun Avatar() { - val avatarRequest = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { - if (it != null) - try { - val inputStream = baseContext.contentResolver.openInputStream(it) - val avatarBitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() - val scaledBitmap = avatarBitmap.scale(192, 192) - val exif = ExifInterface(baseContext.contentResolver.openInputStream(it)!!) - val orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - val rotatedBitmap = rotateBitmap(scaledBitmap, orientation) - if (tmpFile != null && tmpFile!!.exists()) - Utils.deleteFile(tmpFile!!) - newId = System.currentTimeMillis() - tmpFile = File(BaresipService.filesPath, "${newId}.png") - if (Utils.saveBitmap(rotatedBitmap, tmpFile!!)) { - imageAvatarUri.value = Uri.fromFile(tmpFile).toString() - } - } catch (e: Exception) { - Log.e(TAG, "Could not read avatar image: ${e.message}") - } - } - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(96.dp) - .combinedClickable( - onClick = { - avatarRequest.launch("image/*") - }, - onLongClick = { - textAvatarColor.intValue = Utils.randomColor() - color = textAvatarColor.intValue - imageAvatarUri.value = "" - } - ), - ) { - if (imageAvatarUri.value == "") { - textAvatarText.value = if (new) - "?" - else - "${name[0]}" - textAvatarColor.intValue = color - TextAvatar(96) - } else - ImageAvatar(96) - } - } - } - - @Composable - fun ContactName() { - val focusRequester = FocusRequester() - var contactName by remember { mutableStateOf(name) } - newName = contactName - OutlinedTextField( - value = contactName, - placeholder = { Text(stringResource(R.string.contact_name)) }, - onValueChange = { - contactName = it - newName = contactName - }, - modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText - ), - label = { Text(stringResource(R.string.contact_name)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - capitalization = KeyboardCapitalization.Words - ) - ) - LaunchedEffect(new) { - if (new) - focusRequester.requestFocus() - } - } - - @Composable - fun ContactUri() { - var contactUri by remember { mutableStateOf(uri) } - newUri = contactUri - OutlinedTextField( - value = contactUri, - placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, - onValueChange = { - contactUri = it - newUri = contactUri - }, - modifier = Modifier.fillMaxWidth(), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText - ), - label = { Text(stringResource(R.string.sip_or_tel_uri)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - - @Composable - fun Favorite() { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.favorite), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.favorite) - alertMessage.value = getString(R.string.favorite_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - ) - var favoriteContact by remember { mutableStateOf(favorite) } - newFavorite = favoriteContact - Switch( - checked = favoriteContact, - onCheckedChange = { - favoriteContact = it - newFavorite = favoriteContact - } - ) - } - } - - @Composable - fun Android() { - if (new && BaresipService.contactsMode != "baresip") - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.android), - modifier = Modifier.weight(1f), - color = LocalCustomColors.current.itemText - ) - var androidContact by remember { mutableStateOf(android) } - newAndroid = androidContact - Switch( - checked = androidContact, - onCheckedChange = { - if (BaresipService.contactsMode != "android") { - androidContact = it - newAndroid = androidContact - } - } - ) - } - } - - private fun checkOnClick(ctx: Context) { - if (BaresipService.activities.indexOf("baresip contact,$new,$uriOrName") == -1) - return - - newUri = newUri.filterNot{setOf('-', ' ', '(', ')').contains(it)} - if (!newUri.startsWith("sip:") && !newUri.startsWith("tel:")) - newUri = if (Utils.isTelNumber(newUri)) - "tel:$newUri" - else - "sip:$newUri" - if (!Utils.checkUri(newUri)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), newUri) - showAlert.value = true - return - } - - newName = newName.trim() - if (newName == "") newName = newUri.substringAfter(":") - if (!Utils.checkName(newName)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_contact), newName) - showAlert.value = true - return - } - - val alert: Boolean = if (new) - Contact.nameExists(newName, BaresipService.contacts,true) - else { - (uriOrName != newName) && Contact.nameExists(newName, BaresipService.contacts, false) - } - if (alert) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.contact_already_exists), newName) - showAlert.value = true - return - } - - val contact: Contact.BaresipContact = - Contact.BaresipContact(newName, newUri, color, newId, newFavorite) - - if (imageAvatarUri.value == "") { - if (contact.avatarImage != null) { - contact.avatarImage = null - Utils.deleteFile(File(BaresipService.filesPath, "${newId}.png")) - } - } - else { - val imageFilePath = BaresipService.filesPath + "/${newId}.png" - contact.avatarImage = BitmapFactory.decodeFile(imageFilePath) - } - - if (newAndroid) - addOrUpdateAndroidContact(ctx, contact) - else { - if (new) - Contact.addBaresipContact(contact) - else - Contact.updateBaresipContact(id, contact) - } - - BaresipService.activities.remove("baresip contact,$new,$uriOrName") - - val i = Intent(ctx, MainActivity::class.java) - if (newAndroid && contact.favorite) - i.putExtra("name", newName) - setResult(RESULT_OK, i) - finish() - } - - private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap { - val matrix = Matrix() - when (orientation) { - ExifInterface.ORIENTATION_NORMAL -> return bitmap - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> { - matrix.setRotate(180f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_TRANSPOSE -> { - matrix.setRotate(90f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) - ExifInterface.ORIENTATION_TRANSVERSE -> { - matrix.setRotate(-90f) - matrix.postScale(-1f, 1f) - } - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) - else -> return bitmap - } - val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, - bitmap.width, bitmap.height, matrix, true) - bitmap.recycle() - return rotatedBitmap - } - - private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) { - val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID) - val selection = ContactsContract.Data.MIMETYPE + "='" + - CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " + - CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'" - val c: Cursor? = ctx.contentResolver.query( - ContactsContract.Data.CONTENT_URI, projection, - selection, null, null) - if (c != null && c.moveToFirst()) { - updateAndroidContact(c.getLong(0), contact) - } else { - addAndroidContact(ctx, contact) - } - c?.close() - } - - private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean { - val ops = ArrayList() - ops.add( - ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) - .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) - .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build()) - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name) - .build()) - val mimeType = if (contact.uri.startsWith("sip:")) - CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE - else - CommonDataKinds.Phone.CONTENT_ITEM_TYPE - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, mimeType) - .withValue(Data.DATA1, contact.uri.substringAfter(":")) - .build()) - - if (contact.avatarImage != null) { - val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!) - if (photoData != null) { - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValueBackReference(Data.RAW_CONTACT_ID, 0) - .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Photo.PHOTO, photoData) - .build()) - } - } - try { - ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of contact ${contact.name} failed: ${e.message}") - return false - } - return true - } - - private fun updateAndroidContact(rawContactId: Long, contact: Contact.BaresipContact) { - if (updateAndroidUri(rawContactId, contact.uri) == 0) - addAndroidUri(rawContactId, contact.uri) - if (updateAndroidPhoto(rawContactId, contact.avatarImage) == 0) - if (contact.avatarImage != null) - addAndroidPhoto(rawContactId, contact.avatarImage!!) - } - - private fun addAndroidUri(rawContactId: Long, uri: String) { - val mimeType = if (uri.startsWith("sip:")) - CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE - else - CommonDataKinds.Phone.CONTENT_ITEM_TYPE - val ops = ArrayList() - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValue(Data.RAW_CONTACT_ID, rawContactId) - .withValue(Data.MIMETYPE, mimeType) - .withValue(Data.DATA1, uri.substringAfter(":")) - .build()) - try { - contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of SIP URI $uri failed: ${e.message}") - } - } - - private fun updateAndroidUri(rawContactId: Long, uri: String): Int { - val mimeType = if (uri.startsWith("sip:")) - CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE - else - CommonDataKinds.Phone.CONTENT_ITEM_TYPE - val contentValues = ContentValues() - contentValues.put(ContactsContract.Data.DATA1, uri) - val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + - "${ContactsContract.Data.MIMETYPE}='$mimeType'" - return try { - contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) - } catch (e: Exception) { - Log.e(TAG, "Update of Android URI $uri failed: ${e.message}") - 0 - } - } - - private fun addAndroidPhoto(rawContactId: Long, photoBits: Bitmap) { - val photoBytes = bitmapToPNGByteArray(photoBits) - if (photoBytes != null) { - val ops = ArrayList() - ops.add( - ContentProviderOperation - .newInsert(ContactsContract.Data.CONTENT_URI) - .withValue(Data.RAW_CONTACT_ID, rawContactId) - .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) - .withValue(CommonDataKinds.Photo.PHOTO, photoBytes) - .build()) - try { - contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) - } catch (e: Exception) { - Log.e(TAG, "Adding of Android photo failed: ${e.message}") - } - } - } - - private fun updateAndroidPhoto(rawContactId: Long, photoBits: Bitmap?): Int { - val photoBytes = if (photoBits == null) - null - else - bitmapToPNGByteArray(photoBits) - val contentValues = ContentValues() - contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes) - val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + - "${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'" - return try { - contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) - } catch (e: Exception) { - Log.e(TAG, "updateAndroidPhoto failed: ${e.message}") - 0 - } - } - - private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? { - val size = bitmap.width * bitmap.height * 4 - val out = ByteArrayOutputStream(size) - return try { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - out.flush() - out.close() - out.toByteArray() - } catch (e: Exception) { - Log.w(TAG, "Unable to serialize photo: ${e.message}") - null - } - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("baresip contact,$new,$uriOrName") - setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java)) - finish() - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt new file mode 100644 index 00000000..36986dd9 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt @@ -0,0 +1,819 @@ +package com.tutpro.baresip + +import android.content.ContentProviderOperation +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds +import android.provider.ContactsContract.Contacts.Data +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.LaunchedEffect +import androidx.compose.runtime.getValue +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +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.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import coil.compose.rememberAsyncImagePainter +import com.tutpro.baresip.CustomElements.AlertDialog +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream + +fun NavGraphBuilder.baresipContactScreenRoute(navController: NavController) { + composable( + route = "baresip_contact/{uri_or_name}/{kind}", + arguments = listOf( + navArgument("uri_or_name") { type = NavType.StringType }, + navArgument("kind") { type = NavType.StringType } + ) + ) { backStackEntry -> + val uriOrNameArg = backStackEntry.arguments?.getString("uri_or_name")!! + val kindArg = backStackEntry.arguments?.getString("kind")!! + ContactScreen( + navController = navController, + uriOrNameArg = uriOrNameArg, + kindArg = kindArg + ) + } +} + +private data class ScreenState( + val new: Boolean = false, + val favorite: Boolean = false, + val android: Boolean = false, + val id: Long = 0, + val newId: Long = 0, + val name: String = "", + val uri: String = "", + val color: Int = 0, + val avatarImageUri: String? = null, + val tmpAvatarFile: File? = null, + val isLoading: Boolean = true +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContactScreen( + navController: NavController, + uriOrNameArg: String, + kindArg: String +) { + + val ctx = LocalContext.current + + var screenState by remember { mutableStateOf(ScreenState()) } + + val title = if (screenState.new) + stringResource(R.string.new_contact) + else + uriOrNameArg + + LaunchedEffect(key1 = uriOrNameArg, key2 = kindArg) { + val isNew = kindArg == "new" + if (isNew) { + val time = System.currentTimeMillis() + screenState = ScreenState( + new = true, + name = "", + uri = uriOrNameArg, + favorite = false, + android = BaresipService.contactsMode == "android", + color = Utils.randomColor(), + id = time, + newId = time, + isLoading = false + ) + } + else { + val contact = Contact.baresipContact(uriOrNameArg)!! + val avatarFile = File(BaresipService.filesPath, "${contact.id}.png") + screenState = ScreenState( + new = false, + name = uriOrNameArg, + uri = contact.uri, + favorite = contact.favorite, + android = false, + color = contact.color, + id = contact.id, + newId = contact.id, + avatarImageUri = if (contact.avatarImage != null && avatarFile.exists()) + Uri.fromFile(avatarFile).toString() + else + null, + isLoading = false + ) + } + } + + val onBack: () -> Unit = { + screenState.tmpAvatarFile?.let { tempFile -> + if (tempFile.exists()) { + Log.d(TAG, "Back pressed, deleting temp avatar: ${tempFile.name}") + Utils.deleteFile(tempFile) + } + } + navController.popBackStack() + } + + val onCheck: () -> Unit = { + val result = checkOnClick( + ctx = ctx, + currentState = screenState, + uriOrNameArg = uriOrNameArg, + ) + if (result) + navController.popBackStack() + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar(title, onBack = onBack, onCheck = onCheck) + } + }, + content = { contentPadding -> + if (!screenState.isLoading) { + ContactContent( + contentPadding = contentPadding, + screenState = screenState, + onStateChange = { newState -> screenState = newState } + ) + } else { + // Optional: Show a loading indicator + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar(title: String, onBack: () -> Unit, onCheck: () -> Unit) { + TopAppBar( + title = { + Text( + text = title, + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = onCheck) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) +} + +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ContactContent( + contentPadding: PaddingValues, + screenState: ScreenState, + onStateChange: (ScreenState) -> Unit +) { + val ctx = LocalContext.current + + if (showAlert.value) { + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar( + ctx = ctx, + name = screenState.name, + color = screenState.color, + currentAvatarUri = screenState.avatarImageUri, + onNewAvatarChosen = { processedTmpFile, generatedNewId -> + onStateChange( + screenState.copy( + avatarImageUri = Uri.fromFile(processedTmpFile).toString(), + tmpAvatarFile = processedTmpFile, + newId = generatedNewId + ) + ) + }, + onAvatarColorChange = { newRandomColor -> + // User long-clicked to change color, this means discarding any image. + // The old tempAvatarFile (if any) should be deleted. + screenState.tmpAvatarFile?.let { + if (it.exists()) + Utils.deleteFile(it) + } + onStateChange( + screenState.copy( + color = newRandomColor, + avatarImageUri = null, + tmpAvatarFile = null + ) + ) + } + ) + ContactName( + name = screenState.name, + new = screenState.new, + onNameChange = { newName -> onStateChange(screenState.copy(name = newName)) } + ) + ContactUri( + uri = screenState.uri, + onUriChange = { newUri -> onStateChange(screenState.copy(uri = newUri)) } + ) + Favorite( + ctx = ctx, + favorite = screenState.favorite, + onFavoriteChange = { + newFavorite -> onStateChange(screenState.copy(favorite = newFavorite)) + } + ) + if (screenState.new && BaresipService.contactsMode == "both") + Android( + android = screenState.android, + onAndroidChange = { newAndroid -> onStateChange(screenState.copy(android = newAndroid)) } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Avatar( + ctx: Context, + name: String, + color: Int, + currentAvatarUri: String?, + onNewAvatarChosen: (newImageFile: File, newImageId: Long) -> Unit, + onAvatarColorChange: (newColor: Int) -> Unit +) { + val avatarImagePicker = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) { + try { + val inputStream = ctx.contentResolver.openInputStream(uri) + val avatarBitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (avatarBitmap == null) { + Log.e(TAG, "Failed to decode bitmap from URI: $uri") + return@rememberLauncherForActivityResult + } + + val scaledBitmap = avatarBitmap.scale(192, 192) // Define desired scale + + val orientationInputStream = ctx.contentResolver.openInputStream(uri) + val exif = if (orientationInputStream != null) ExifInterface(orientationInputStream) else null + orientationInputStream?.close() + val orientation = exif?.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) ?: ExifInterface.ORIENTATION_NORMAL + val rotatedBitmap = rotateBitmap(scaledBitmap, orientation) + + val newImageId = System.currentTimeMillis() + val tempNewImageFile = File(BaresipService.filesPath, "${newImageId}.png") + + if (saveBitmap(rotatedBitmap, tempNewImageFile)) { + onNewAvatarChosen(tempNewImageFile, newImageId) + } else { + Log.e(TAG, "Failed to save processed avatar image to ${tempNewImageFile.absolutePath}") + if (tempNewImageFile.exists()) Utils.deleteFile(tempNewImageFile) + } + } catch (e: Exception) { + Log.e(TAG, "Could not process avatar image: ${e.message}") + } + } + } + + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(avatarSize.dp) + .clip(CircleShape) + .background(if (currentAvatarUri == null) Color(color) else Color.Transparent) + .combinedClickable( + onClick = { + avatarImagePicker.launch("image/*") + }, + onLongClick = { + onAvatarColorChange(Utils.randomColor()) + } + ) + ) { + if (currentAvatarUri == null) { + Box( + modifier = Modifier.size(avatarSize.dp), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle(SolidColor(Color(color))) + } + val text = if (name.isNotBlank()) name.substring(0, 1).uppercase() else "?" + Text(text, fontSize = 72.sp, color = Color.White) + } + } else { + Image( + painter = rememberAsyncImagePainter(model = currentAvatarUri), + contentDescription = stringResource(R.string.avatar_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(avatarSize.dp).clip(CircleShape) + ) + } + } + } +} + +@Composable +private fun ContactName(name: String, new: Boolean, onNameChange: (String) -> Unit) { + val focusRequester = FocusRequester() + OutlinedTextField( + value = name, + placeholder = { Text(stringResource(R.string.contact_name)) }, + onValueChange = onNameChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { Text(stringResource(R.string.contact_name)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Words + ) + ) + LaunchedEffect(new) { + if (new) + focusRequester.requestFocus() + } +} + +@Composable +private fun ContactUri(uri: String, onUriChange: (String) -> Unit) { + OutlinedTextField( + value = uri, + placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, + onValueChange = onUriChange, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { Text(stringResource(R.string.sip_or_tel_uri)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) +} + +@Composable +private fun Favorite(ctx: Context, favorite: Boolean, onFavoriteChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.favorite), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.favorite) + alertMessage.value = ctx.getString(R.string.favorite_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + ) + Switch( + checked = favorite, + onCheckedChange = onFavoriteChange + ) + } +} + +@Composable +private fun Android(android: Boolean, onAndroidChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.android), + modifier = Modifier.weight(1f), + color = LocalCustomColors.current.itemText + ) + Switch( + checked = android, + onCheckedChange = onAndroidChange + ) + } +} + +private fun checkOnClick( + ctx: Context, + currentState: ScreenState, + uriOrNameArg: String +): Boolean { + + var newUri = currentState.uri.filterNot{setOf('-', ' ', '(', ')').contains(it)} + if (!newUri.startsWith("sip:") && !newUri.startsWith("tel:")) + newUri = if (Utils.isTelNumber(newUri)) + "tel:$newUri" + else + "sip:$newUri" + + if (!Utils.checkUri(newUri)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), newUri) + showAlert.value = true + return false + } + + var newName = currentState.name.trim() + if (newName == "") newName = newUri.substringAfter(":") + if (!Utils.checkName(newName)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_contact), newName) + showAlert.value = true + return false + } + + val alert: Boolean = if (currentState.new) + Contact.nameExists(newName, BaresipService.contacts, true) + else { + (uriOrNameArg != newName) && Contact.nameExists(newName, BaresipService.contacts, false) + } + if (alert) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.contact_already_exists), newName) + showAlert.value = true + return false + } + + var idToUse = currentState.id + + if (currentState.tmpAvatarFile != null && currentState.tmpAvatarFile.exists()) { + if (currentState.id != currentState.newId) { // Avatar changed, implying new ID + // Delete old avatar if it existed for currentState.contactId + val oldAvatar = File(BaresipService.filesPath, "${currentState.id}.png") + if (oldAvatar.exists()) Utils.deleteFile(oldAvatar) + idToUse = currentState.newId // Use the new ID for the contact + } + /*val avatarFile = File(BaresipService.filesPath, "$idToUse.png") + if (!Utils.moveFile(currentState.tempAvatarFile, avatarFile)) { + Log.e(TAG, "Failed to move tmp avatar file $idToUse.png") + return "Failed to save avatar" + }*/ + } else if (currentState.avatarImageUri == null) { // Avatar was explicitly cleared + val avatarFile = File(BaresipService.filesPath, "$idToUse.png") + if (avatarFile.exists()) + Utils.deleteFile(avatarFile) + } + + val contact: Contact.BaresipContact = + Contact.BaresipContact( + newName, + newUri, + currentState.color, + idToUse, + currentState.favorite + ) + + if (currentState.avatarImageUri == null) + contact.avatarImage = null + else { + val imageFilePath = BaresipService.filesPath + "/${idToUse}.png" + contact.avatarImage = BitmapFactory.decodeFile(imageFilePath) + } + + if (currentState.android) { + addOrUpdateAndroidContact(ctx, contact) + if (contact.favorite) { + val contentValues = ContentValues() + contentValues.put(ContactsContract.Contacts.STARRED, 1) + try { + ctx.contentResolver.update( + ContactsContract.RawContacts.CONTENT_URI, contentValues, + ContactsContract.Contacts.DISPLAY_NAME + "='" + newName + "'", null + ) + } catch (e: Exception) { + Log.e(TAG, "Update of Android favorite failed: ${e.message}") + } + } + } + else { + if (currentState.new) + Contact.addBaresipContact(contact) + else + Contact.updateBaresipContact(currentState.id, contact) + } + + return true +} + +private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_NORMAL -> return bitmap + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) + else -> return bitmap + } + val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, + bitmap.width, bitmap.height, matrix, true) + bitmap.recycle() + return rotatedBitmap +} + +private fun saveBitmap(bitmap: Bitmap, file: File): Boolean { + if (file.exists()) file.delete() + try { + val out = FileOutputStream(file) + val scaledBitmap = bitmap.scale(avatarSize, avatarSize) + scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + out.flush() + out.close() + Log.d(TAG, "Saved bitmap to ${file.absolutePath} of length ${file.length()}") + } catch (e: Exception) { + Log.e(TAG, "Failed to save bitmap to ${file.absolutePath}: $e") + return false + } + return true +} + +private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) { + val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID) + val selection = ContactsContract.Data.MIMETYPE + "='" + + CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " + + CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'" + val c: Cursor? = ctx.contentResolver.query( + ContactsContract.Data.CONTENT_URI, projection, + selection, null, null) + if (c != null && c.moveToFirst()) { + updateAndroidContact(ctx, c.getLong(0), contact) + } else { + addAndroidContact(ctx, contact) + } + c?.close() +} + +private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean { + val ops = ArrayList() + ops.add( + ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build()) + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name) + .build()) + val mimeType = if (contact.uri.startsWith("sip:")) + CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + else + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, mimeType) + .withValue(Data.DATA1, contact.uri.substringAfter(":")) + .build()) + + if (contact.avatarImage != null) { + val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!) + if (photoData != null) { + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(Data.RAW_CONTACT_ID, 0) + .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.Photo.PHOTO, photoData) + .build()) + } + } + try { + ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of contact ${contact.name} failed: ${e.message}") + return false + } + return true +} + +private fun updateAndroidContact(ctx: Context, rawContactId: Long, contact: Contact.BaresipContact) { + if (updateAndroidUri(ctx, rawContactId, contact.uri) == 0) + addAndroidUri(ctx, rawContactId, contact.uri) + if (updateAndroidPhoto(ctx, rawContactId, contact.avatarImage) == 0) + if (contact.avatarImage != null) + addAndroidPhoto(ctx, rawContactId, contact.avatarImage!!) +} + +private fun addAndroidUri(ctx: Context, rawContactId: Long, uri: String) { + val mimeType = if (uri.startsWith("sip:")) + CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + else + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + val ops = ArrayList() + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValue(Data.RAW_CONTACT_ID, rawContactId) + .withValue(Data.MIMETYPE, mimeType) + .withValue(Data.DATA1, uri.substringAfter(":")) + .build()) + try { + ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of SIP URI $uri failed: ${e.message}") + } +} + +private fun updateAndroidUri(ctx: Context, rawContactId: Long, uri: String): Int { + val mimeType = if (uri.startsWith("sip:")) + CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + else + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + val contentValues = ContentValues() + contentValues.put(ContactsContract.Data.DATA1, uri) + val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + + "${ContactsContract.Data.MIMETYPE}='$mimeType'" + return try { + ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) + } catch (e: Exception) { + Log.e(TAG, "Update of Android URI $uri failed: ${e.message}") + 0 + } +} + +private fun addAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap) { + val photoBytes = bitmapToPNGByteArray(photoBits) + if (photoBytes != null) { + val ops = ArrayList() + ops.add( + ContentProviderOperation + .newInsert(ContactsContract.Data.CONTENT_URI) + .withValue(Data.RAW_CONTACT_ID, rawContactId) + .withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE) + .withValue(CommonDataKinds.Photo.PHOTO, photoBytes) + .build()) + try { + ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } catch (e: Exception) { + Log.e(TAG, "Adding of Android photo failed: ${e.message}") + } + } +} + +private fun updateAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap?): Int { + val photoBytes = if (photoBits == null) + null + else + bitmapToPNGByteArray(photoBits) + val contentValues = ContentValues() + contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes) + val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " + + "${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'" + return try { + ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null) + } catch (e: Exception) { + Log.e(TAG, "updateAndroidPhoto failed: ${e.message}") + 0 + } +} + +private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? { + val size = bitmap.width * bitmap.height * 4 + val out = ByteArrayOutputStream(size) + return try { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + out.flush() + out.close() + out.toByteArray() + } catch (e: Exception) { + Log.w(TAG, "Unable to serialize photo: ${e.message}") + null + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index 01bf3797..f7eea096 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -49,21 +49,19 @@ import android.provider.ContactsContract import android.provider.Settings import android.system.OsConstants import android.telecom.TelecomManager -import android.text.Spannable -import android.text.SpannableString -import android.text.style.ForegroundColorSpan import android.view.View import android.widget.RemoteViews import android.widget.Toast -import androidx.annotation.ColorRes import androidx.annotation.Keep -import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.app.ActivityCompat 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.net.toUri import androidx.lifecycle.MutableLiveData @@ -553,8 +551,13 @@ class BaresipService: Service() { 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()) + else { + Message.updateAorMessage( + ua.account.aor, + intent.getStringExtra("time")!!.toLong() + ) + ua.account.unreadMessages = Message.unreadMessages(ua.account.aor) + } nm.cancel(MESSAGE_NOTIFICATION_ID) } @@ -563,9 +566,50 @@ class BaresipService: Service() { 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()) + 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) } @@ -844,15 +888,15 @@ class BaresipService: Service() { if (!Utils.isVisible()) { val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val intent = Intent(applicationContext, MainActivity::class.java) + .putExtra("action", "call show") + .putExtra("callp", callp) intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - intent.putExtra("action", "call show") - .putExtra("callp", callp) - val pi = PendingIntent.getActivity(applicationContext, CALL_REQ_CODE, intent, - piFlags) + val pi = PendingIntent.getActivity(applicationContext, CALL_REQ_CODE, intent, piFlags) val nb = NotificationCompat.Builder(this, if (shouldVibrate()) MEDIUM_CHANNEL_ID else HIGH_CHANNEL_ID) val caller = Utils.friendlyUri(this, peerUri, ua.account) + val person = Person.Builder().setName(caller).build() nb.setSmallIcon(R.drawable.ic_stat_call) .setColor(ContextCompat.getColor(this, R.color.colorBaresip)) .setContentIntent(pi) @@ -867,19 +911,15 @@ class BaresipService: Service() { .setPriority(NotificationCompat.PRIORITY_HIGH) .setFullScreenIntent(pi, true) val answerIntent = Intent(applicationContext, MainActivity::class.java) - answerIntent.putExtra("action", "call answer") + .putExtra("action", "call answer") .putExtra("callp", callp) val api = PendingIntent.getActivity(applicationContext, ANSWER_REQ_CODE, answerIntent, piFlags) val rejectIntent = Intent(this, BaresipService::class.java) rejectIntent.action = "Call Reject" rejectIntent.putExtra("callp", callp) - val rpi = PendingIntent.getService(this, REJECT_REQ_CODE, - rejectIntent, piFlags) - nb.addAction(R.drawable.ic_stat_call, - getActionText(R.string.answer, R.color.colorGreen), api) - nb.addAction(R.drawable.ic_stat_call_end, - getActionText(R.string.reject, R.color.colorRed), rpi) + val rpi = PendingIntent.getService(this, REJECT_REQ_CODE, rejectIntent, piFlags) + nb.setStyle(NotificationCompat.CallStyle.forIncomingCall(person, rpi, api)) nm.notify(CALL_NOTIFICATION_ID, nb.build()) return } @@ -938,8 +978,7 @@ class BaresipService: Service() { 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(applicationContext, TRANSFER_REQ_CODE, - intent, piFlags) + val pi = PendingIntent.getActivity(applicationContext, 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_stat_call) @@ -952,7 +991,7 @@ class BaresipService: Service() { val acceptIntent = Intent(applicationContext, 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") + acceptIntent.putExtra("action", "transfer accept") .putExtra("callp", callp).putExtra("uri", ev[1]) val acceptPendingIntent = PendingIntent.getActivity(applicationContext, ACCEPT_REQ_CODE, acceptIntent, piFlags) @@ -1078,16 +1117,6 @@ class BaresipService: Service() { } - private fun postServiceEvent(event: ServiceEvent) { - 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}") - } - } - @Suppress("unused") @SuppressLint("UnspecifiedImmutableFlag") @Keep @@ -1120,45 +1149,88 @@ class BaresipService: Service() { ua.account.unreadMessages = true if (!Utils.isVisible()) { + + // common flags val piFlags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + + // message show val intent = Intent(applicationContext, 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) + intent.putExtra("action", "message show").putExtra("uap", uap).putExtra("peer", peerUri) val pi = PendingIntent.getActivity(applicationContext, MESSAGE_REQ_CODE, intent, piFlags) + + // message notification builder + val senderDisplayName = Utils.friendlyUri(this, peerUri, ua.account) + val senderPerson = Person.Builder() + .setName(senderDisplayName) + .setKey(peerUri) + .build() + 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, senderPerson) val nb = NotificationCompat.Builder(this, HIGH_CHANNEL_ID) - val sender = Utils.friendlyUri(this, peerUri, ua.account) - nb.setSmallIcon(R.drawable.ic_stat_message) + .setSmallIcon(R.drawable.ic_stat_message) .setColor(ContextCompat.getColor(this, R.color.colorBaresip)) .setContentIntent(pi) .setSound(Settings.System.DEFAULT_NOTIFICATION_URI) .setAutoCancel(true) - .setContentTitle(getString(R.string.message_from) + " " + sender) - .setContentText(text) - val replyIntent = Intent(applicationContext, MainActivity::class.java) - replyIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or - Intent.FLAG_ACTIVITY_NEW_TASK - replyIntent.putExtra("action", "message reply") - .putExtra("uap", uap).putExtra("peer", peerUri) - val rpi = PendingIntent.getActivity(applicationContext, REPLY_REQ_CODE, replyIntent, - piFlags) + .setStyle(messagingStyle) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + + // messafe inline reply + 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_stat_reply, + getString(R.string.reply), + directReplyPendingIntent + ).addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY). + build() + + // message save val saveIntent = Intent(this, BaresipService::class.java) saveIntent.action = "Message Save" - saveIntent.putExtra("uap", uap) - .putExtra("time", timeStampString) - val savePendingIntent = PendingIntent.getService(this, SAVE_REQ_CODE, saveIntent, - piFlags) + saveIntent.putExtra("uap", uap).putExtra("time", timeStampString) + val savePendingIntent = PendingIntent.getService(this, SAVE_REQ_CODE, saveIntent, piFlags) + val saveAction = NotificationCompat.Action.Builder( + R.drawable.ic_stat_save, + getString(R.string.save), + savePendingIntent + ).build() + + // message delete val deleteIntent = Intent(this, BaresipService::class.java) deleteIntent.action = "Message Delete" - deleteIntent.putExtra("uap", uap) - .putExtra("time", timeStampString) - val deletePendingIntent = PendingIntent.getService(this, DELETE_REQ_CODE, - deleteIntent, piFlags) - nb.addAction(R.drawable.ic_stat_reply, "Reply", rpi) - nb.addAction(R.drawable.ic_stat_save, "Save", savePendingIntent) - nb.addAction(R.drawable.ic_stat_delete, "Delete", deletePendingIntent) + deleteIntent.putExtra("uap", uap).putExtra("time", timeStampString) + val deletePendingIntent = PendingIntent.getService(this, DELETE_REQ_CODE, deleteIntent, piFlags) + val deleteAction = NotificationCompat.Action.Builder( + R.drawable.ic_stat_delete, + getString(R.string.delete), + deletePendingIntent + ).build() + + nb.addAction(inlineReplyAction).addAction(saveAction).addAction(deleteAction) nm.notify(MESSAGE_NOTIFICATION_ID, nb.build()) + return } @@ -1297,13 +1369,6 @@ class BaresipService: Service() { } } - private fun getActionText(@StringRes stringRes: Int, @ColorRes colorRes: Int): Spannable { - val spannable: Spannable = SpannableString(applicationContext.getText(stringRes)) - spannable.setSpan( - ForegroundColorSpan(applicationContext.getColor(colorRes)), 0, spannable.length, 0) - return spannable - } - private fun startRinging() { am.mode = AudioManager.MODE_RINGTONE rt!!.isLooping = true @@ -1638,12 +1703,9 @@ class BaresipService: Service() { val baresipContacts = mutableStateOf(emptyList()) val androidContacts = mutableStateOf(emptyList()) val contactNames = mutableStateOf(emptyList()) - val contactUpdate = MutableLiveData() val darkTheme = mutableStateOf(false) var messages by mutableStateOf(emptyList()) val messageUpdate = MutableLiveData() - val chatTexts: MutableMap = mutableMapOf() - val activities = mutableListOf() val registrationUpdate = MutableLiveData() val serviceEvent = MutableLiveData>() val serviceEvents = mutableListOf() @@ -1661,12 +1723,25 @@ class BaresipService: Service() { private var aec: AcousticEchoCanceler? = null var agcAvailable = false var rt: Ringtone? = null + private var agc: AutomaticGainControl? = null private val nsAvailable = NoiseSuppressor.isAvailable() private var ns: NoiseSuppressor? = null private var btAdapter: BluetoothAdapter? = null private var recorderSessionId = 0 + internal const val KEY_TEXT_REPLY = "key_text_reply_baresip" + + fun postServiceEvent(event: ServiceEvent) { + 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) { diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt deleted file mode 100644 index b5a66383..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsActivity.kt +++ /dev/null @@ -1,404 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.media.AudioAttributes -import android.media.MediaPlayer -import android.os.Build -import android.os.Bundle -import android.text.format.DateUtils -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.fillMaxHeight -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.safeDrawingPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import com.tutpro.baresip.CallsActivity.Companion.uaHistory -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.verticalScrollbar -import java.io.File -import java.io.IOException -import java.text.DateFormat -import java.util.GregorianCalendar - -class CallDetailsActivity : ComponentActivity() { - - private lateinit var aor: String - private lateinit var peer: String - private lateinit var account: Account - private lateinit var callRow: CallRow - private lateinit var details: ArrayList - private val decPlayer = MediaPlayer() - private val encPlayer = MediaPlayer() - private var position = 0 - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - aor = intent.getStringExtra("aor")!! - account = Account.ofAor(aor)!! - peer = intent.getStringExtra("peer")!! - position = intent.getIntExtra("position", 0) - callRow = uaHistory.value[position] - details = callRow.details - - Utils.addActivity("call_details,$aor,$peer,$position") - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - CallDetailsScreen(this, stringResource(R.string.call_details)) { goBack() } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun CallDetailsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - ) - }, - content = { contentPadding -> - CallDetailsContent(ctx, contentPadding) - } - ) - } - - @Composable - fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding) - .padding(top = 16.dp, start = 16.dp, end = 4.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Peer(ctx, peer, account) - Calls(ctx) - } - } - - @Composable - fun Peer(ctx: Context, peer: String, account: Account) { - val headerText = stringResource(R.string.peer) + " " + - Utils.friendlyUri(ctx, peer, account) - Text( - text = headerText, - modifier = Modifier.fillMaxWidth(), - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = LocalCustomColors.current.itemText, - textAlign = TextAlign.Center - ) - } - - @Composable - fun Calls(ctx: Context) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = stringResource(R.string.direction), - color = LocalCustomColors.current.itemText, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.width(96.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(text = stringResource(R.string.time), - color = LocalCustomColors.current.itemText, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.weight(1f)) - Text(text = stringResource(R.string.calls_duration), - modifier = Modifier.padding(end = 12.dp), - color = LocalCustomColors.current.itemText, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - val lazyListState = rememberLazyListState() - LazyColumn( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .verticalScrollbar( - state = lazyListState, - width = 4.dp, - color = LocalCustomColors.current.gray - ) - .background(LocalCustomColors.current.background), - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(details) { detail -> - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - painter = painterResource(detail.direction), - contentDescription = "Direction", - modifier = Modifier.width(64.dp) - ) - Spacer(modifier = Modifier.width(38.dp)) - val durationText = startTime(detail) - Spacer(modifier = Modifier.weight(1f)) - Duration(ctx, detail, durationText) - } - } - } - } - - @Composable - fun startTime(detail: CallRow.Details): String { - val startTime = detail.startTime - val stopTime = detail.stopTime - val startTimeText: String - val durationText: String - val stopText = if (DateUtils.isToday(stopTime.timeInMillis)) { - val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) - stringResource(R.string.today) + " " + fmt.format(stopTime.time) - } else { - val fmt = DateFormat.getDateTimeInstance() - fmt.format(stopTime.time) - } - if (startTime == GregorianCalendar(0, 0, 0)) { - startTimeText = stopText - durationText = "?" - } else { - if (startTime == null) { - startTimeText = stopText - durationText = "" - } else { - val startText = if (DateUtils.isToday(startTime.timeInMillis)) { - val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) - stringResource(R.string.today) + " " + fmt.format(startTime.time) - } else { - val fmt = DateFormat.getDateTimeInstance() - fmt.format(startTime.time) - } - startTimeText = startText - val duration = (stopTime.time.time - startTime.time.time) / 1000 - durationText = DateUtils.formatElapsedTime(duration) - } - } - Text(text = startTimeText, color = LocalCustomColors.current.itemText) - return durationText - } - - @Composable - fun Duration(ctx: Context, detail: CallRow.Details, durationText: String) { - - val showDialog = remember { mutableStateOf(false) } - val recording = detail.recording - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.playing_recording), - message = "", - ) - - if (recording[0] != "") { - Text( - text = durationText, - color = LocalCustomColors.current.accent, - modifier = Modifier.padding(end = 12.dp) - .clickable(onClick = { - if (!decPlayer.isPlaying && !encPlayer.isPlaying) { - decPlayer.reset() - encPlayer.reset() - Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}") - decPlayer.apply { - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setOnPreparedListener { - encPlayer.apply { - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setOnPreparedListener { - it.start() - decPlayer.start() - Log.d(TAG, "Started players") - showDialog.value = true - } - setOnCompletionListener { - Log.d(TAG, "Stopping encPlayer") - it.stop() - showDialog.value = false - } - try { - val file = recording[0] - val encFile = File(file) - .copyTo(File(BaresipService.filesPath + - "/tmp/encode.wav"), true) - val encUri = encFile.toUri() - setDataSource(ctx, encUri) - prepareAsync() - } catch (e: IllegalArgumentException) { - Log.e(TAG, "encPlayer IllegalArgumentException: $e") - } catch (e: IOException) { - Log.e(TAG, "encPlayer IOException: $e") - } catch (e: Exception) { - Log.e(TAG, "encPlayer Exception: $e") - } - } - } - setOnCompletionListener { - Log.d(TAG, "Stopping decPlayer") - it.stop() - showDialog.value = false - } - try { - val file = recording[1] - val decFile = File(file) - .copyTo(File(BaresipService.filesPath + - "/tmp/decode.wav"), true) - val decUri = decFile.toUri() - setDataSource(ctx, decUri) - prepareAsync() - } catch (e: IllegalArgumentException) { - Log.e(TAG, "decPlayer IllegalArgumentException: $e") - } catch (e: IOException) { - Log.e(TAG, "decPlayer IOException: $e") - } catch (e: Exception) { - Log.e(TAG, "decPlayer Exception: $e") - } - } - } else if (decPlayer.isPlaying && encPlayer.isPlaying) { - decPlayer.stop() - encPlayer.stop() - } - }) - ) - } else { - Text(text = durationText, - modifier = Modifier.padding(end = 12.dp), - color = LocalCustomColors.current.itemText) - } - } - - override fun onPause() { - MainActivity.activityAor = aor - super.onPause() - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("call_details,$aor,$peer,$position") - decPlayer.stop() - decPlayer.release() - encPlayer.stop() - encPlayer.release() - finish() - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt new file mode 100644 index 00000000..93c8a7af --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt @@ -0,0 +1,326 @@ +package com.tutpro.baresip + +import android.content.Context +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.text.format.DateUtils +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.asPaddingValues +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.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.CallRow.Details +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.io.File +import java.io.IOException +import java.text.DateFormat +import java.util.GregorianCalendar + +fun NavGraphBuilder.callDetailsScreenRoute(navController: NavController, viewModel: ViewModel) { + composable("call_details") { backStackEntry -> + val callRow = remember { viewModel.consumeSelectedCallRow() } + CallDetailsScreen(navController, callRow!!) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CallDetailsScreen(navController: NavController, callRow: CallRow) { + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.call_details), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton(onClick = navController::popBackStack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + ) + } + }, + content = { contentPadding -> + CallDetailsContent(LocalContext.current, contentPadding, callRow) + }, + ) +} + +@Composable +private fun CallDetailsContent(ctx: Context, contentPadding: PaddingValues, callRow: CallRow) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(top = 16.dp, start = 16.dp, end = 4.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Peer(ctx, callRow) + Details(ctx, callRow.details) + } +} + +@Composable +private fun Peer(ctx: Context, callRow: CallRow) { + val account = Account.ofAor(callRow.aor)!! + val headerText = stringResource(R.string.peer) + " " + Utils.friendlyUri(ctx, callRow.peerUri, account) + Text( + text = headerText, + modifier = Modifier.fillMaxWidth(), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = LocalCustomColors.current.itemText, + textAlign = TextAlign.Center + ) +} + +@Composable +private fun Details(ctx: Context, details: ArrayList
) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = stringResource(R.string.direction), + color = LocalCustomColors.current.itemText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.width(96.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(R.string.time), + color = LocalCustomColors.current.itemText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.weight(1f)) + Text(text = stringResource(R.string.calls_duration), + modifier = Modifier.padding(end = 12.dp), + color = LocalCustomColors.current.itemText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxWidth() + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(details) { detail -> + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(detail.direction), + contentDescription = "Direction", + modifier = Modifier.width(64.dp) + ) + Spacer(modifier = Modifier.width(38.dp)) + val durationText = startTime(detail) + Spacer(modifier = Modifier.weight(1f)) + Duration(ctx, detail, durationText) + } + } + } +} + +@Composable +private fun startTime(detail: Details): String { + val startTime = detail.startTime + val stopTime = detail.stopTime + val startTimeText: String + val durationText: String + val stopText = if (DateUtils.isToday(stopTime.timeInMillis)) { + val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) + stringResource(R.string.today) + " " + fmt.format(stopTime.time) + } else { + val fmt = DateFormat.getDateTimeInstance() + fmt.format(stopTime.time) + } + if (startTime == GregorianCalendar(0, 0, 0)) { + startTimeText = stopText + durationText = "?" + } else { + if (startTime == null) { + startTimeText = stopText + durationText = "" + } else { + val startText = if (DateUtils.isToday(startTime.timeInMillis)) { + val fmt = DateFormat.getTimeInstance(DateFormat.MEDIUM) + stringResource(R.string.today) + " " + fmt.format(startTime.time) + } else { + val fmt = DateFormat.getDateTimeInstance() + fmt.format(startTime.time) + } + startTimeText = startText + val duration = (stopTime.time.time - startTime.time.time) / 1000 + durationText = DateUtils.formatElapsedTime(duration) + } + } + Text(text = startTimeText, color = LocalCustomColors.current.itemText) + return durationText +} + +@Composable +private fun Duration(ctx: Context, detail: Details, durationText: String) { + + val showDialog = remember { mutableStateOf(false) } + val recording = detail.recording + val decPlayer = MediaPlayer() + val encPlayer = MediaPlayer() + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.playing_recording), + message = "", + ) + + if (recording[0] != "") { + Text( + text = durationText, + color = LocalCustomColors.current.accent, + modifier = Modifier.padding(end = 12.dp) + .clickable(onClick = { + if (!decPlayer.isPlaying && !encPlayer.isPlaying) { + decPlayer.reset() + encPlayer.reset() + Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}") + decPlayer.apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setOnPreparedListener { + encPlayer.apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setOnPreparedListener { + it.start() + decPlayer.start() + Log.d(TAG, "Started players") + showDialog.value = true + } + setOnCompletionListener { + Log.d(TAG, "Stopping encPlayer") + it.stop() + showDialog.value = false + } + try { + val file = recording[0] + val encFile = File(file) + .copyTo(File(BaresipService.filesPath + + "/tmp/encode.wav"), true) + val encUri = encFile.toUri() + setDataSource(ctx, encUri) + prepareAsync() + } catch (e: IllegalArgumentException) { + Log.e(TAG, "encPlayer IllegalArgumentException: $e") + } catch (e: IOException) { + Log.e(TAG, "encPlayer IOException: $e") + } catch (e: Exception) { + Log.e(TAG, "encPlayer Exception: $e") + } + } + } + setOnCompletionListener { + Log.d(TAG, "Stopping decPlayer") + it.stop() + showDialog.value = false + } + try { + val file = recording[1] + val decFile = File(file) + .copyTo(File(BaresipService.filesPath + + "/tmp/decode.wav"), true) + val decUri = decFile.toUri() + setDataSource(ctx, decUri) + prepareAsync() + } catch (e: IllegalArgumentException) { + Log.e(TAG, "decPlayer IllegalArgumentException: $e") + } catch (e: IOException) { + Log.e(TAG, "decPlayer IOException: $e") + } catch (e: Exception) { + Log.e(TAG, "decPlayer Exception: $e") + } + } + } else if (decPlayer.isPlaying && encPlayer.isPlaying) { + decPlayer.stop() + encPlayer.stop() + } + }) + ) + } else { + Text(text = durationText, + modifier = Modifier.padding(end = 12.dp), + color = LocalCustomColors.current.itemText) + } +} + diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallRow.kt b/app/src/main/kotlin/com/tutpro/baresip/CallRow.kt index 45d849a2..b2aaf5a7 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallRow.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallRow.kt @@ -5,9 +5,7 @@ import java.util.* class CallRow( val aor: String, val peerUri: String, val direction: Int, startTime: GregorianCalendar?, val stopTime: GregorianCalendar, val recording: Array -) -{ - +) { class Details( val direction: Int, val startTime: GregorianCalendar?, val stopTime: GregorianCalendar, val recording: Array diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt deleted file mode 100644 index c5f2c8a4..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsActivity.kt +++ /dev/null @@ -1,545 +0,0 @@ -package com.tutpro.baresip - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.os.Build -import android.os.Bundle -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -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.fillMaxHeight -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.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -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.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.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.ImageAvatar -import com.tutpro.baresip.CustomElements.TextAvatar -import com.tutpro.baresip.CustomElements.verticalScrollbar - -class CallsActivity : ComponentActivity() { - - private lateinit var account: Account - - private var aor = "" - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - public override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - val title = getString(R.string.call_history) - - aor = intent.getStringExtra("aor")!! - Utils.addActivity("calls,$aor") - - val ua = UserAgent.ofAor(aor)!! - account = ua.account - aorGenerateHistory(aor) - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - CallsScreen(this, title) { goBack() } - } - } - } - - account.missedCalls = false - } - - @Composable - fun CallsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(title, navigateBack) }, - content = { contentPadding -> - CallsContent(ctx, contentPadding) - } - ) - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(title: String, navigateBack: () -> Unit) { - - var expanded by remember { mutableStateOf(false) } - - val delete = stringResource(R.string.delete) - val disable = stringResource(R.string.disable_history) - val enable = stringResource(R.string.enable_history) - - val showDialog = remember { mutableStateOf(false) } - val positiveAction = remember { mutableStateOf({}) } - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = String.format(stringResource(R.string.delete_history_alert), aor.substringAfter(":")), - positiveButtonText = stringResource(R.string.delete), - negativeButtonText = stringResource(R.string.cancel), - onPositiveClicked = positiveAction.value, - ) - - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton( - onClick = { expanded = !expanded } - ) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Menu", - tint = LocalCustomColors.current.light - ) - } - CustomElements.DropdownMenu(expanded, - { expanded = false }, - listOf(delete, if (account.callHistory) disable else enable), - onItemClick = { selectedItem -> - expanded = false - when (selectedItem) { - delete -> { - positiveAction.value = { - CallHistoryNew.clear(aor) - uaHistory.value = emptyList() - } - showDialog.value = true - } - disable, enable -> { - account.callHistory = !account.callHistory - AccountsActivity.saveAccounts() - } - } - } - ) - } - ) - } - - @Composable - fun CallsContent(ctx: Context, contentPadding: PaddingValues) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Account(account) - Calls(ctx, account) - } - } - - @Composable - fun Account(account: Account) { - val headerText = stringResource(R.string.account) + " " + - if (account.nickName.value != "") - account.nickName.value - else - aor.split(":")[1] - Text( - text = headerText, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = LocalCustomColors.current.itemText, - textAlign = TextAlign.Center - ) - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun Calls(ctx: Context, account: Account) { - - val showDialog = remember { mutableStateOf(false) } - val message = remember { mutableStateOf("") } - val positiveButtonText = remember { mutableStateOf("") } - val positiveAction = remember { mutableStateOf({}) } - val neutralButtonText = remember { mutableStateOf("") } - val neutralAction = remember { mutableStateOf({}) } - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = message.value, - positiveButtonText = positiveButtonText.value, - onPositiveClicked = positiveAction.value, - neutralButtonText = neutralButtonText.value, - onNeutralClicked = neutralAction.value, - negativeButtonText = stringResource(R.string.cancel) - ) - - val lazyListState = rememberLazyListState() - LazyColumn( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp) - .verticalScrollbar( - state = lazyListState, - width = 4.dp, - color = LocalCustomColors.current.gray - ) - .background(LocalCustomColors.current.background), - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(items = uaHistory.value) { callRow -> - var recordings = false - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Box(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.combinedClickable( - onClick = { - val peerUri = callRow.peerUri - val peerName = Utils.friendlyUri(ctx, peerUri, account) - message.value = String.format(getString(R.string.contact_action_question), peerName) - positiveButtonText.value = getString(R.string.call) - positiveAction.value = { - BaresipService.activities.remove("calls,$aor") - MainActivity.activityAor = aor - returnResult() - val i = Intent(this@CallsActivity, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "call") - i.putExtra("uap", UserAgent.ofAor(aor)!!.uap) - i.putExtra("peer", peerUri) - startActivity(i) - } - neutralButtonText.value = getString(R.string.send_message) - neutralAction.value = { - BaresipService.activities.remove("calls,$aor") - MainActivity.activityAor = aor - returnResult() - val i = Intent(this@CallsActivity, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "message") - i.putExtra("uap", UserAgent.ofAor(aor)!!.uap) - i.putExtra("peer", peerUri) - startActivity(i) - } - showDialog.value = true - }, - onLongClick = { - val peerUri = callRow.peerUri - val peerName = Utils.friendlyUri(ctx, peerUri, account) - val callText: String = if (callRow.details.size > 1) - getString(R.string.calls_calls) - else - getString(R.string.calls_call) - val contactExists = Contact.nameExists(peerName, BaresipService.contacts, false) - if (contactExists) { - message.value = String.format( - getString(R.string.calls_delete_question), - peerName, callText - ) - positiveButtonText.value = getString(R.string.delete) - positiveAction.value = { - removeFromHistory(callRow) - } - neutralButtonText.value = "" - } - else { - message.value = String.format( - getString(R.string.calls_add_delete_question), - peerName, callText - ) - positiveButtonText.value = getString(R.string.add_contact) - positiveAction.value = { - val i = Intent(ctx, BaresipContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", callRow.peerUri) - i.putExtras(b) - ctx.startActivity(i) - } - neutralButtonText.value = getString(R.string.delete) - neutralAction.value = { - removeFromHistory(callRow) - } - } - showDialog.value = true - } - ) - ) { - val uri = callRow.peerUri - when (val contact = Contact.findContact(uri)) { - is Contact.BaresipContact -> { - val avatarImage = contact.avatarImage - if (avatarImage != null) - ImageAvatar(avatarImage) - else - TextAvatar(contact.name, contact.color) - } - is Contact.AndroidContact -> { - val thumbNailUri = contact.thumbnailUri - if (thumbNailUri != null) - AsyncImage( - model = thumbNailUri, - contentDescription = "Avatar", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - ) - else - TextAvatar(contact.name, contact.color) - } - null -> { - val avatarImage = BitmapFactory - .decodeResource(ctx.resources, R.drawable.person_image) - ImageAvatar(avatarImage) - } - } - Spacer(modifier = Modifier.width(4.dp)) - var count = 1 - for (d in callRow.details) { - if (d.recording[0] != "") - recordings = true - if (count > 3) - continue - Image(painterResource(d.direction), "Direction") - count++ - } - if (count > 3) - Text("...", color = LocalCustomColors.current.itemText) - Text(text = Utils.friendlyUri(ctx, callRow.peerUri, account), - modifier = Modifier.padding(start = 8.dp), - fontSize = 18.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = LocalCustomColors.current.itemText - ) - } - } - Box(modifier = Modifier.width(56.dp)) { - Text( - text = Utils.relativeTime(ctx, callRow.stopTime), - fontSize = 12.sp, - minLines = 2, maxLines = 2, - lineHeight = 16.sp, - textAlign = TextAlign.End, - color = if (recordings) - LocalCustomColors.current.accent - else - LocalCustomColors.current.itemText, - modifier = Modifier - .padding(end = 16.dp) - .width(64.dp) - .clickable(onClick = { - val i = Intent(ctx, CallDetailsActivity::class.java) - val b = Bundle() - b.putString("aor", account.aor) - b.putString("peer", callRow.peerUri) - b.putInt("position", uaHistory.value.indexOf(callRow)) - i.putExtras(b) - ctx.startActivity(i) - }) - ) - } - } - } - } - } - - private fun goBack() { - BaresipService.activities.remove("calls,$aor") - returnResult() - } - - private fun returnResult() { - setResult(RESULT_CANCELED, Intent()) - finish() - } - - override fun onPause() { - MainActivity.activityAor = aor - super.onPause() - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun aorGenerateHistory(aor: String) { - uaHistory.value = emptyList() - for (i in BaresipService.callHistory.indices.reversed()) { - val h = BaresipService.callHistory[i] - if (h.aor == aor) { - val direction: Int = if (h.direction == "in") { - if (h.startTime != null) { - R.drawable.call_down_green - } else { - if (h.rejected) - R.drawable.call_down_red - else - R.drawable.call_missed_in - } - } else { - if (h.startTime != null) { - R.drawable.call_up_green - } else { - if (h.rejected) - R.drawable.call_up_red - else - R.drawable.call_missed_out - } - } - if (uaHistory.value.isNotEmpty() && (uaHistory.value.last().peerUri == h.peerUri)) - uaHistory.value.last().details.add(CallRow.Details( - direction, h.startTime, - h.stopTime, h.recording - )) - else - addToUaHistory( - CallRow(h.aor, h.peerUri, direction, h.startTime, h.stopTime, h.recording) - ) - } - } - } - - private fun removeFromHistory(callRow: CallRow) { - for (details in callRow.details) { - if (details.recording[0] != "") - CallHistoryNew.deleteRecording(details.recording) - BaresipService.callHistory.removeAll { - it.startTime == details.startTime && it.stopTime == details.stopTime - } - } - CallHistoryNew.deleteRecording(callRow.recording) - deleteFromUaHistory(callRow) - CallHistoryNew.save() - } - - private fun addToUaHistory(callRow: CallRow) { - val updatedList = uaHistory.value.toMutableList() - updatedList.add(callRow) - uaHistory.value = updatedList - } - - private fun deleteFromUaHistory(callRow: CallRow) { - val updatedList = uaHistory.value.toMutableList() - updatedList.remove(callRow) - uaHistory.value = updatedList - } - - @SuppressLint("MutableCollectionMutableState") - companion object { - val uaHistory = mutableStateOf(emptyList()) - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt new file mode 100644 index 00000000..1cf33423 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt @@ -0,0 +1,483 @@ +package com.tutpro.baresip + +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.asPaddingValues +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.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.verticalScrollbar + +fun NavGraphBuilder.callsScreenRoute(navController: NavController, viewModel: ViewModel) { + composable( + route = "calls/{aor}", + arguments = listOf(navArgument("aor") { type = NavType.StringType }) + ) { backStackEntry -> + val aor = backStackEntry.arguments?.getString("aor")!! + CallsScreen(navController, viewModel, aor) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CallsScreen(navController: NavController, viewModel: ViewModel, aor: String) { + + val account = Account.ofAor(aor)!! + + val callHistory: MutableState> = remember { mutableStateOf(emptyList()) } + + var isHistoryLoaded by remember { mutableStateOf(false) } + LaunchedEffect(aor) { + callHistory.value = loadCallHistory(aor) + isHistoryLoaded = true + } + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar(navController, account, callHistory) + } + }, + content = { contentPadding -> + if (isHistoryLoaded) + CallsContent( + LocalContext.current, + navController, + viewModel, + contentPadding, + account, + callHistory + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar(navController: NavController, account: Account, callHistory: MutableState>) { + + var expanded by remember { mutableStateOf(false) } + + val delete = stringResource(R.string.delete) + val disable = stringResource(R.string.disable_history) + val enable = stringResource(R.string.enable_history) + + val showDialog = remember { mutableStateOf(false) } + val positiveAction = remember { mutableStateOf({}) } + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = String.format(stringResource(R.string.delete_history_alert), account.text()), + positiveButtonText = stringResource(R.string.delete), + negativeButtonText = stringResource(R.string.cancel), + onPositiveClicked = positiveAction.value, + ) + + TopAppBar( + title = { + Text( + text = stringResource(R.string.call_history), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton( + onClick = { + navController.popBackStack() + account.missedCalls = false + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton( + onClick = { expanded = !expanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + tint = LocalCustomColors.current.light + ) + } + CustomElements.DropdownMenu( + expanded, + { expanded = false }, + listOf(delete, if (account.callHistory) disable else enable), + onItemClick = { selectedItem -> + expanded = false + when (selectedItem) { + delete -> { + positiveAction.value = { + CallHistoryNew.clear(account.aor) + callHistory.value = emptyList() + } + showDialog.value = true + } + + disable, enable -> { + account.callHistory = !account.callHistory + Account.saveAccounts() + } + } + } + ) + } + ) +} + +@Composable +private fun CallsContent( + ctx: Context, + navController: NavController, + viewModel: ViewModel, + contentPadding: PaddingValues, + account: Account, + callHistory: MutableState> +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Account(account) + Calls(ctx, navController, viewModel, account, callHistory) + } +} + +@Composable +private fun Account(account: Account) { + val headerText = stringResource(R.string.account) + " " + + if (account.nickName.value != "") + account.nickName.value + else + account.aor.split(":")[1] + Text( + text = headerText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = LocalCustomColors.current.itemText, + textAlign = TextAlign.Center + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Calls( + ctx: Context, + navController: NavController, + viewModel: ViewModel, + account: Account, + callHistory: MutableState> +) { + + val showDialog = remember { mutableStateOf(false) } + val message = remember { mutableStateOf("") } + val positiveButtonText = remember { mutableStateOf("") } + val positiveAction = remember { mutableStateOf({}) } + val neutralButtonText = remember { mutableStateOf("") } + val neutralAction = remember { mutableStateOf({}) } + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = message.value, + positiveButtonText = positiveButtonText.value, + onPositiveClicked = positiveAction.value, + neutralButtonText = neutralButtonText.value, + onNeutralClicked = neutralAction.value, + negativeButtonText = stringResource(R.string.cancel) + ) + + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = callHistory.value, key = { callRow -> callRow.stopTime }) { callRow -> + var recordings = false + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.combinedClickable( + onClick = { + val aor = account.aor + val ua = UserAgent.ofAor(aor) + val peerUri = callRow.peerUri + val intent = Intent(ctx, MainActivity::class.java) + if (ua != null) { + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", peerUri) + } + else + Log.w(TAG, "onClickListener did not find UA for $aor") + val peerName = Utils.friendlyUri(ctx, peerUri, account) + message.value = String.format(ctx.getString(R.string.contact_action_question), peerName) + positiveButtonText.value = ctx.getString(R.string.call) + positiveAction.value = { + if (ua != null) { + handleIntent(ctx, viewModel, intent, "call") + navController.popBackStack() + } + } + neutralButtonText.value = ctx.getString(R.string.send_message) + neutralAction.value = { + if (ua != null) { + handleIntent(ctx, viewModel, intent, "message") + navController.popBackStack() + } + } + showDialog.value = true + }, + onLongClick = { + val peerUri = callRow.peerUri + val peerName = Utils.friendlyUri(ctx, peerUri, account) + val callText: String = if (callRow.details.size > 1) + ctx.getString(R.string.calls_calls) + else + ctx.getString(R.string.calls_call) + val contactExists = Contact.nameExists(peerName, BaresipService.contacts, false) + if (contactExists) { + message.value = String.format( + ctx.getString(R.string.calls_delete_question), + peerName, callText + ) + positiveButtonText.value = ctx.getString(R.string.delete) + positiveAction.value = { + removeFromHistory(callHistory, callRow) + } + neutralButtonText.value = "" + } + else { + message.value = String.format( + ctx.getString(R.string.calls_add_delete_question), + peerName, callText + ) + positiveButtonText.value = ctx.getString(R.string.add_contact) + positiveAction.value = { + navController.navigate("baresip_contact/${callRow.peerUri}/new") + } + neutralButtonText.value = ctx.getString(R.string.delete) + neutralAction.value = { + removeFromHistory(callHistory, callRow) + } + } + showDialog.value = true + } + ) + ) { + val uri = callRow.peerUri + when (val contact = Contact.findContact(uri)) { + is Contact.BaresipContact -> { + val avatarImage = contact.avatarImage + if (avatarImage != null) + CustomElements.ImageAvatar(avatarImage) + else + CustomElements.TextAvatar(contact.name, contact.color) + } + is Contact.AndroidContact -> { + val thumbNailUri = contact.thumbnailUri + if (thumbNailUri != null) + AsyncImage( + model = thumbNailUri, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + else + CustomElements.TextAvatar(contact.name, contact.color) + } + null -> { + val avatarImage = BitmapFactory + .decodeResource(ctx.resources, R.drawable.person_image) + CustomElements.ImageAvatar(avatarImage) + } + } + Spacer(modifier = Modifier.width(4.dp)) + var count = 1 + for (d in callRow.details) { + if (d.recording[0] != "") + recordings = true + if (count > 3) + continue + Image(painterResource(d.direction), "Direction") + count++ + } + if (count > 3) + Text("...", color = LocalCustomColors.current.itemText) + Text(text = Utils.friendlyUri(ctx, callRow.peerUri, account), + modifier = Modifier.padding(start = 8.dp), + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalCustomColors.current.itemText + ) + } + } + Box(modifier = Modifier.width(56.dp)) { + Text( + text = Utils.relativeTime(ctx, callRow.stopTime), + fontSize = 12.sp, + minLines = 2, maxLines = 2, + lineHeight = 16.sp, + textAlign = TextAlign.End, + color = if (recordings) + LocalCustomColors.current.accent + else + LocalCustomColors.current.itemText, + modifier = Modifier + .padding(end = 16.dp) + .width(64.dp) + .clickable(onClick = { + viewModel.selectCallRow(callRow) + navController.navigate("call_details") + }) + ) + } + } + } + } +} + +private fun loadCallHistory(aor: String): MutableList { + val res = mutableListOf() + for (i in BaresipService.callHistory.indices.reversed()) { + val h = BaresipService.callHistory[i] + if (h.aor == aor) { + val direction: Int = if (h.direction == "in") { + if (h.startTime != null) { + R.drawable.call_down_green + } else { + if (h.rejected) + R.drawable.call_down_red + else + R.drawable.call_missed_in + } + } else { + if (h.startTime != null) { + R.drawable.call_up_green + } else { + if (h.rejected) + R.drawable.call_up_red + else + R.drawable.call_missed_out + } + } + if (res.isNotEmpty() && res.last().peerUri == h.peerUri) + res.last().details.add(CallRow.Details( + direction, h.startTime, + h.stopTime, h.recording + )) + else + res.add(CallRow(h.aor, h.peerUri, direction, h.startTime, h.stopTime, h.recording)) + } + } + return res +} + +private fun removeFromHistory(callHistory: MutableState>, callRow: CallRow) { + for (details in callRow.details) { + if (details.recording[0] != "") + CallHistoryNew.deleteRecording(details.recording) + BaresipService.callHistory.removeAll { + it.startTime == details.startTime && it.stopTime == details.stopTime + } + } + CallHistoryNew.deleteRecording(callRow.recording) + val updatedList = callHistory.value.filterNot { it == callRow } + callHistory.value = updatedList + CallHistoryNew.save() +} + diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt deleted file mode 100644 index 466447ce..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatActivity.kt +++ /dev/null @@ -1,637 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.SystemClock -import android.text.format.DateUtils.isToday -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Observer -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.EventListener -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.verticalScrollbar -import kotlinx.coroutines.launch -import java.text.DateFormat -import java.util.GregorianCalendar - -class ChatActivity : ComponentActivity() { - - private lateinit var imm: InputMethodManager - private lateinit var aor: String - private lateinit var account: Account - private lateinit var peerUri: String - private lateinit var ua: UserAgent - - private var _chatMessages = mutableStateOf>(emptyList()) - private var chatMessages : List by _chatMessages - private var focus = false - private var lastCall: Long = 0 - private var keyboardController: SoftwareKeyboardController? = null - private val chatPeer = mutableStateOf("") - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - aor = intent.getStringExtra("aor")!! - peerUri = intent.getStringExtra("peer")!! - account = UserAgent.ofAor(aor)!!.account - focus = intent.getBooleanExtra("focus", false) - - if (BaresipService.activities.first().startsWith("chat,$aor,$peerUri")) { - returnResult(RESULT_CANCELED) - return - } else { - Utils.addActivity("chat,$aor,$peerUri,$focus") - } - - val userAgent = UserAgent.ofAor(aor) - if (userAgent == null) { - Log.w(TAG, "ChatActivity did not find ua of $aor") - MainActivity.activityAor = aor - returnResult(RESULT_CANCELED) - return - } else { - ua = userAgent - } - - chatPeer.value = Utils.friendlyUri(this, peerUri, userAgent.account, true) - - imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - - val messagesObserver = Observer { - _chatMessages.value = listOf() - _chatMessages.value = uaPeerMessages(aor, peerUri) - } - BaresipService.messageUpdate.observe(this, messagesObserver) - - ua.account.unreadMessages = false - - setContent { - AppTheme { - keyboardController = LocalSoftwareKeyboardController.current - Surface( - modifier = Modifier.fillMaxSize(), - ) { - ChatScreen(this) { goBack() } - } - } - } - } - - @Composable - fun ChatScreen(ctx: Context, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(ctx, navigateBack) }, - bottomBar = { NewMessage(ctx, peerUri) }, - content = { contentPadding -> - ChatContent(ctx, contentPadding) - } - ) - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(ctx: Context, navigateBack: () -> Unit) { - TopAppBar( - title = { - Text( - text = String.format(getString(R.string.chat_with), chatPeer.value), - color = LocalCustomColors.current.light, - fontSize = 22.sp, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton( - onClick = { - if (SystemClock.elapsedRealtime() - lastCall > 1000) { - lastCall = SystemClock.elapsedRealtime() - val intent = Intent(ctx, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - intent.putExtra("action", "call") - intent.putExtra("uap", ua.uap) - intent.putExtra("peer", peerUri) - startActivity(intent) - } - } - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.call_small), - contentDescription = "Call", - tint = LocalCustomColors.current.light - ) - } - } - ) - } - - @Composable - fun ChatContent(ctx: Context, contentPadding: PaddingValues) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding), - verticalArrangement = Arrangement.Bottom - ) { - Account(account) - Spacer(modifier = Modifier.weight(1f)) - Messages(ctx) - } - } - - @Composable - fun Account(account: Account) { - val headerText = getString(R.string.account) + " " + - if (account.nickName.value != "") - account.nickName.value - else - aor.split(":")[1] - Text( - text = headerText, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 8.dp), - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center - ) - } - - @Composable - fun Messages(ctx: Context) { - - val showDialog = remember { mutableStateOf(false) } - val dialogMessage = remember { mutableStateOf("") } - val positiveButtonText = remember { mutableStateOf("") } - val positiveAction = remember { mutableStateOf({}) } - val neutralButtonText = remember { mutableStateOf("") } - val neutralAction = remember { mutableStateOf({}) } - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = dialogMessage.value, - positiveButtonText = positiveButtonText.value, - onPositiveClicked = positiveAction.value, - neutralButtonText = neutralButtonText.value, - onNeutralClicked = neutralAction.value, - negativeButtonText = stringResource(R.string.cancel) - ) - - val lazyListState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - _chatMessages.value = uaPeerMessages(aor, peerUri) - } - - LaunchedEffect(chatMessages) { - // Scroll to the bottom when new messages are added - if (chatMessages.isNotEmpty()) { - coroutineScope.launch { - lazyListState.scrollToItem(0) - } - } - } - - LazyColumn( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .padding(start = 16.dp, end = 2.dp) - .verticalScrollbar( - state = lazyListState, - width = 4.dp, - color = LocalCustomColors.current.gray - ), - reverseLayout = true, - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(items = chatMessages, key = { message -> message.timeStamp }) { message -> - val down = message.direction == MESSAGE_DOWN - val peer: String = if (down) { - if (chatPeer.value.startsWith("sip:") && - (Utils.uriHostPart(message.peerUri) == Utils.uriHostPart(message.aor))) - Utils.uriUserPart(message.peerUri) - else - chatPeer.value - } - else - stringResource(R.string.you) - var info: String - val cal = GregorianCalendar() - cal.timeInMillis = message.timeStamp - val fmt: DateFormat = if (isToday(message.timeStamp)) - DateFormat.getTimeInstance(DateFormat.SHORT) - else - DateFormat.getDateInstance(DateFormat.SHORT) - info = fmt.format(cal.time) - if (info.length < 6) info = "${stringResource(R.string.today)} $info" - if (message.direction == MESSAGE_UP_FAIL) { - info = if (message.responseCode != 0) - "$info - ${stringResource(R.string.message_failed)}: " + "${message.responseCode} ${message.responseReason}" - else - "$info - ${stringResource(R.string.sending_failed)}" - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(end = 12.dp) - ) { - Button( - onClick = { - if (Contact.findContact(peerUri) == null) { - dialogMessage.value = String.format(getString(R.string.long_message_question), - chatPeer.value - ) - positiveButtonText.value = getString(R.string.add_contact) - positiveAction.value = { - val i = Intent(ctx, BaresipContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", peerUri) - i.putExtras(b) - ctx.startActivity(i) - } - neutralButtonText.value = getString(R.string.delete) - neutralAction.value = { - message.delete() - _chatMessages.value = uaPeerMessages(aor, peerUri) - } - } - else { - dialogMessage.value = getString(R.string.short_message_question) - positiveButtonText.value = getString(R.string.delete) - positiveAction.value = { - message.delete() - _chatMessages.value = uaPeerMessages(aor, peerUri) - } - neutralButtonText.value = "" - } - showDialog.value = true - }, - shape = if (message.direction == MESSAGE_DOWN) - RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp) - else - RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp), - colors = ButtonDefaults.buttonColors(containerColor = - if (message.direction == MESSAGE_DOWN) { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.secondaryDark - else - LocalCustomColors.current.secondaryLight - } - else { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.primaryDark - else - LocalCustomColors.current.primaryLight - }), - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding( - start = if (message.direction == MESSAGE_DOWN) 0.dp else 24.dp, - end = if (message.direction == MESSAGE_DOWN) 24.dp else 0.dp - ) - ) { - Column { - Row { - val textColor = - if (message.direction == MESSAGE_DOWN) { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.secondaryLight - else - LocalCustomColors.current.secondaryDark - } - else { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.primaryLight - else - LocalCustomColors.current.primaryDark - } - Text(text = peer, fontSize = 12.sp, color = textColor) - Spacer(modifier = Modifier.weight(1f)) - Text(text = info, fontSize = 12.sp, color = textColor) - } - Row { - SelectionContainer { - Text( - text = message.message, - color = LocalCustomColors.current.itemText, - fontWeight = if (message.direction == MESSAGE_DOWN && message.new) - FontWeight.Bold else FontWeight.Normal - ) - } - } - } - } - } - } - } - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun NewMessage(ctx: Context, peerUri: String) { - - var newMessage = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue("")) - } - var textFieldLoaded by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - - EventListener { - when (it) { - Lifecycle.Event.ON_RESUME -> { - val chatText = BaresipService.chatTexts["$aor::$peerUri"] - if (chatText != null) { - Log.d(TAG, "Restoring newMessage '$chatText' for $aor::$peerUri") - newMessage.value = TextFieldValue( - text = chatText, - selection = TextRange(chatText.length) - ) - BaresipService.chatTexts.remove("$aor::$peerUri") - } - } - Lifecycle.Event.ON_PAUSE -> { - if (newMessage.value != TextFieldValue("")) { - Log.d(TAG, "Saving newMessage '${newMessage.value.text}' for $aor::$peerUri") - BaresipService.chatTexts["$aor::$peerUri"] = newMessage.value.text - } - } - else -> {} - } - } - - val showDialog = remember { mutableStateOf(false) } - val dialogMessage = remember { mutableStateOf("") } - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.notice), - message = dialogMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - - Row(modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val keyboardController = LocalSoftwareKeyboardController.current - OutlinedTextField( - value = newMessage.value, - placeholder = { Text(stringResource(R.string.new_message)) }, - onValueChange = { newMessage.value = it }, - modifier = Modifier - .weight(1f) - .padding(end = 8.dp) - .verticalScroll(rememberScrollState()) - .focusRequester(focusRequester) - .onGloballyPositioned { - if (!textFieldLoaded) - textFieldLoaded = true - }, - singleLine = false, - trailingIcon = { - if (newMessage.value.text.isNotEmpty()) { - Icon( - Icons.Outlined.Clear, - contentDescription = "Clear", - modifier = Modifier.clickable { newMessage.value = TextFieldValue("") } - ) - } }, - label = { LabelText(stringResource(R.string.new_message)) }, - textStyle = TextStyle(fontSize = 18.sp), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - keyboardType = KeyboardType.Text, - autoCorrectEnabled = true - ) - ) - LaunchedEffect(Unit) { - if (newMessage.value.text.isNotEmpty()) - focusRequester.requestFocus() - } - Image( - painter = painterResource(id = R.drawable.send), - contentDescription = "Send", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clickable { - val msgText = newMessage.value.text - if (msgText.isNotEmpty()) { - keyboardController?.hide() - val time = System.currentTimeMillis() - val msg = - Message(aor, peerUri, msgText, time, MESSAGE_UP_WAIT, 0, "", true) - msg.add() - var msgUri = "" - _chatMessages.value += msg - if (Utils.isTelUri(peerUri)) - if (ua.account.telProvider == "") { - dialogMessage.value = String.format( - getString(R.string.no_telephony_provider), - Utils.plainAor(aor) - ) - showDialog.value = true - } else { - msgUri = Utils.telToSip(peerUri, ua.account) - } - else - msgUri = peerUri - if (msgUri != "") - if (Api.message_send(ua.uap, msgUri, msgText, time.toString()) != 0 - ) { - Toast.makeText( - ctx, "${getString(R.string.message_failed)}!", - Toast.LENGTH_SHORT - ).show() - msg.direction = MESSAGE_UP_FAIL - msg.responseReason = getString(R.string.message_failed) - } else { - newMessage.value = TextFieldValue("") - keyboardController?.hide() - BaresipService.chatTexts.remove("$aor::$peerUri") - } - } - } - ) - } - } - - override fun onPause() { - super.onPause() - MainActivity.activityAor = aor - } - - override fun onResume() { - super.onResume() - _chatMessages.value = uaPeerMessages(aor, peerUri) - chatPeer.value = Utils.friendlyUri(this, peerUri, ua.account, true) - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - var save = false - for (m in chatMessages) { - if (m.new) { - m.new = false - save = true - } - } - if (save) Message.save() - keyboardController?.hide() - BaresipService.activities.remove("chat,$aor,$peerUri,false") - BaresipService.activities.remove("chat,$aor,$peerUri,true") - returnResult(RESULT_OK) - } - - private fun returnResult(code: Int) { - setResult(code, Intent()) - finish() - } - - private fun uaPeerMessages(aor: String, peerUri: String): List { - val res = mutableListOf() - for (m in BaresipService.messages.reversed()) - if ((m.aor == aor) && (m.peerUri == peerUri)) res.add(m) - return res - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt new file mode 100644 index 00000000..e0b365fd --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt @@ -0,0 +1,580 @@ +package com.tutpro.baresip + +import android.content.Context +import android.content.Intent +import android.text.format.DateUtils.isToday +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.Observer +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.verticalScrollbar +import kotlinx.coroutines.launch +import java.lang.String.format +import java.text.DateFormat +import java.util.GregorianCalendar + +fun NavGraphBuilder.chatScreenRoute(navController: NavController, viewModel: ViewModel) { + composable( + route = "chat/{aor}/{peer}", + arguments = listOf( + navArgument("aor") { type = NavType.StringType }, + navArgument("peer") { type = NavType.StringType } + ) + ) { backStackEntry -> + val aor = backStackEntry.arguments?.getString("aor")!! + val peerUri = backStackEntry.arguments?.getString("peer")!! + ChatScreen( + ctx = LocalContext.current, + navController = navController, + viewModel = viewModel, + account = Account.ofAor(aor)!!, + peerUri = peerUri + ) + } +} + +@Composable +private fun ChatScreen( + ctx: Context, + navController: NavController, + viewModel: ViewModel, + account: Account, + peerUri: String +) { + val lifecycleOwner = LocalLifecycleOwner.current + + val aor = account.aor + + var chatMessages by remember(aor, peerUri) { mutableStateOf>(emptyList()) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + Log.d(TAG, "Resumed to ChatScreen for AOR: $aor peer $peerUri") + chatMessages = loadPeerMessages(aor, peerUri) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + var areMessagesLoaded by remember(aor, peerUri) { + mutableStateOf(false) + } + + val reloadMessages = { + Log.d(TAG, "Reloading messages for $aor peer $peerUri") + chatMessages = loadPeerMessages(aor, peerUri) + if (!areMessagesLoaded) + areMessagesLoaded = true + } + + val addMessage = { newMessage: Message -> + chatMessages = chatMessages + newMessage + } + + DisposableEffect(key1 = lifecycleOwner, key2 = account.aor, key3 = peerUri) { + val messagesObserver = Observer { timestamp -> + Log.d(TAG, "Message update received via LiveData for $peerUri, timestamp: $timestamp") + reloadMessages() + } + reloadMessages() // Initial load + Log.d(TAG, "Observing message updates for $peerUri") + BaresipService.messageUpdate.observe(lifecycleOwner, messagesObserver) + onDispose { + Log.d(TAG, "Removing message observer for $peerUri") + BaresipService.messageUpdate.removeObserver(messagesObserver) + } + } + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column(modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar(ctx, navController, viewModel, account, peerUri) + } + }, + bottomBar = { NewMessage(ctx, account, peerUri, addMessage) }, + content = { contentPadding -> + if (areMessagesLoaded) + ChatContent(ctx, navController, contentPadding, account, peerUri, chatMessages, reloadMessages) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + ctx: Context, + navController: NavController, + viewModel: ViewModel, + account: Account, + peerUri: String +) { + val aor = account.aor + + TopAppBar( + title = { + Text( + text = format(ctx.getString(R.string.chat_with), Utils.friendlyUri(ctx, peerUri, account)), + color = LocalCustomColors.current.light, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = { + Message.updateMessagesFromPearRead(aor, peerUri) + account.unreadMessages = Message.unreadMessages(aor) + navController.popBackStack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + actions = { + IconButton( + onClick = { + val ua = UserAgent.ofAor(account.aor) + if (ua != null) { + val intent = Intent(ctx, MainActivity::class.java) + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", peerUri) + handleIntent(ctx, viewModel, intent, "call") + navController.navigate("main") { + popUpTo("main") + launchSingleTop = true + } + } + else + Log.w(TAG, "onClickListener did not find UA for $aor") + } + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.call_small), + contentDescription = "Call", + tint = LocalCustomColors.current.light + ) + } + } + ) +} + +@Composable +private fun ChatContent( + ctx: Context, + navController: NavController, + contentPadding: PaddingValues, + account: Account, + peerUri: String, + messages: List, + onMessageDeleted: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth().padding(contentPadding), + verticalArrangement = Arrangement.Bottom + ) { + Account(ctx, account) + Spacer(modifier = Modifier.weight(1f)) + Messages(ctx, navController, account, peerUri, messages, onMessageDeleted) + } +} + +@Composable +private fun Account(ctx: Context, account: Account) { + val headerText = ctx.getString(R.string.account) + " " + + if (account.nickName.value != "") + account.nickName.value + else + account.aor.substringAfter(":") + Text( + text = headerText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) +} + +@Composable +private fun Messages( + ctx: Context, + navController: NavController, + account: Account, + peerUri: String, + messages: List, + onMessageDeleted: () -> Unit +) { + val peerName = Utils.friendlyUri(ctx, peerUri, account) + + val showDialog = remember { mutableStateOf(false) } + val dialogMessage = remember { mutableStateOf("") } + val positiveButtonText = remember { mutableStateOf("") } + val positiveAction = remember { mutableStateOf({}) } + val neutralButtonText = remember { mutableStateOf("") } + val neutralAction = remember { mutableStateOf({}) } + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = dialogMessage.value, + positiveButtonText = positiveButtonText.value, + onPositiveClicked = positiveAction.value, + neutralButtonText = neutralButtonText.value, + onNeutralClicked = neutralAction.value, + negativeButtonText = stringResource(R.string.cancel) + ) + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(messages) { + // Scroll to the bottom when new messages are added + if (messages.isNotEmpty()) { + coroutineScope.launch { + lazyListState.scrollToItem(0) + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 2.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + reverseLayout = true, + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(items = messages, key = { message -> message.timeStamp }) { message -> + val down = message.direction == MESSAGE_DOWN + val sender: String = if (down) + peerName + else if (BaresipService.uas.value.size == 1) + stringResource(R.string.you) + else + account.text() + var info: String + val cal = GregorianCalendar() + cal.timeInMillis = message.timeStamp + val fmt: DateFormat = if (isToday(message.timeStamp)) + DateFormat.getTimeInstance(DateFormat.SHORT) + else + DateFormat.getDateInstance(DateFormat.SHORT) + info = fmt.format(cal.time) + if (info.length < 6) info = "${stringResource(R.string.today)} $info" + if (message.direction == MESSAGE_UP_FAIL) { + info = if (message.responseCode != 0) + "$info - ${stringResource(R.string.message_failed)}: " + "${message.responseCode} ${message.responseReason}" + else + "$info - ${stringResource(R.string.sending_failed)}" + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp) + ) { + Button( + onClick = { + if (Contact.findContact(peerUri) == null) { + dialogMessage.value = String.format( + ctx.getString(R.string.long_message_question), + peerUri + ) + positiveButtonText.value = ctx.getString(R.string.add_contact) + positiveAction.value = { + navController.navigate("baresip_contact/$peerUri/new") + } + neutralButtonText.value = ctx.getString(R.string.delete) + neutralAction.value = { + message.delete() + onMessageDeleted + } + } else { + dialogMessage.value = ctx.getString(R.string.short_message_question) + positiveButtonText.value = ctx.getString(R.string.delete) + positiveAction.value = { + message.delete() + onMessageDeleted() + } + neutralButtonText.value = "" + } + showDialog.value = true + }, + shape = if (message.direction == MESSAGE_DOWN) + RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp) + else + RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = + if (message.direction == MESSAGE_DOWN) { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.secondaryDark + else + LocalCustomColors.current.secondaryLight + } else { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.primaryDark + else + LocalCustomColors.current.primaryLight + } + ), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding( + start = if (message.direction == MESSAGE_DOWN) 0.dp else 24.dp, + end = if (message.direction == MESSAGE_DOWN) 24.dp else 0.dp + ) + ) { + Column { + Row { + val textColor = + if (message.direction == MESSAGE_DOWN) { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.secondaryLight + else + LocalCustomColors.current.secondaryDark + } else { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.primaryLight + else + LocalCustomColors.current.primaryDark + } + Text(text = sender, fontSize = 12.sp, color = textColor) + Spacer(modifier = Modifier.weight(1f)) + Text(text = info, fontSize = 12.sp, color = textColor) + } + Row { + SelectionContainer { + Text( + text = message.message, + color = LocalCustomColors.current.itemText, + fontWeight = if (message.direction == MESSAGE_DOWN && message.new) + FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun NewMessage( + ctx: Context, + account: Account, + peerUri: String, + addMessage: (message: Message) -> Unit +) { + + val aor = account.aor + val ua = UserAgent.ofAor(aor)!! + + var newMessage = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + var textFieldLoaded by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + val showDialog = remember { mutableStateOf(false) } + val dialogMessage = remember { mutableStateOf("") } + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.notice), + message = dialogMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + + Row(modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = newMessage.value, + placeholder = { Text(stringResource(R.string.new_message)) }, + onValueChange = { newMessage.value = it }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + .verticalScroll(rememberScrollState()) + .focusRequester(focusRequester) + .onGloballyPositioned { + if (!textFieldLoaded) + textFieldLoaded = true + }, + singleLine = false, + trailingIcon = { + if (newMessage.value.text.isNotEmpty()) { + Icon( + Icons.Outlined.Clear, + contentDescription = "Clear", + modifier = Modifier.clickable { newMessage.value = TextFieldValue("") } + ) + } }, + label = { LabelText(stringResource(R.string.new_message)) }, + textStyle = TextStyle(fontSize = 18.sp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + autoCorrectEnabled = true + ) + ) + LaunchedEffect(Unit) { + if (newMessage.value.text.isNotEmpty()) + focusRequester.requestFocus() + } + Image( + painter = painterResource(id = R.drawable.send), + contentDescription = "Send", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clickable { + val msgText = newMessage.value.text + if (msgText.isNotEmpty()) { + keyboardController?.hide() + val time = System.currentTimeMillis() + val msg = + Message(aor, peerUri, msgText, time, MESSAGE_UP_WAIT, 0, "", true) + msg.add() + var msgUri = "" + addMessage(msg) + if (Utils.isTelUri(peerUri)) + if (ua.account.telProvider == "") { + dialogMessage.value = String.format( + ctx.getString(R.string.no_telephony_provider), + Utils.plainAor(aor) + ) + showDialog.value = true + } else { + msgUri = Utils.telToSip(peerUri, ua.account) + } + else + msgUri = peerUri + if (msgUri != "") + if (Api.message_send(ua.uap, msgUri, msgText, time.toString()) != 0 + ) { + Toast.makeText( + ctx, "${ctx.getString(R.string.message_failed)}!", + Toast.LENGTH_SHORT + ).show() + msg.direction = MESSAGE_UP_FAIL + msg.responseReason = ctx.getString(R.string.message_failed) + } else { + newMessage.value = TextFieldValue("") + keyboardController?.hide() + } + } + } + ) + } +} + +private fun loadPeerMessages(aor: String, peerUri: String): List { + val res = mutableListOf() + for (m in BaresipService.messages.reversed()) + if ((m.aor == aor) && (m.peerUri == peerUri)) res.add(m) + return res +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt deleted file mode 100644 index 1bf33129..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatsActivity.kt +++ /dev/null @@ -1,728 +0,0 @@ -package com.tutpro.baresip - -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.os.Build -import android.os.Bundle -import android.text.format.DateUtils.isToday -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.animation.animateContentSize -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.Surface -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.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.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.tutpro.baresip.BaresipService.Companion.contactNames -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.ImageAvatar -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.TextAvatar -import com.tutpro.baresip.CustomElements.verticalScrollbar -import com.tutpro.baresip.CustomElements.SelectableAlertDialog -import java.text.DateFormat -import java.util.GregorianCalendar - -class ChatsActivity: ComponentActivity() { - - internal lateinit var aor: String - internal lateinit var account: Account - private lateinit var chatRequest: ActivityResultLauncher - - private var _uaMessages = mutableStateOf>(emptyList()) - private var uaMessages: List by _uaMessages - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - public override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - aor = intent.extras!!.getString("aor")!! - Utils.addActivity("chats,$aor") - account = UserAgent.ofAor(aor)!!.account - - val title = getString(R.string.chats) - - _uaMessages.value = uaMessages(aor) - - chatRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) - _uaMessages.value = uaMessages(aor) - } - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - ChatsScreen(this, title) { goBack() } - } - } - } - } - - @Composable - fun ChatsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(title, navigateBack) }, - bottomBar = { NewChatPeer() }, - content = { contentPadding -> - ChatsContent(ctx, contentPadding) - } - ) - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(title: String, navigateBack: () -> Unit) { - - var expanded by remember { mutableStateOf(false) } - - val delete = stringResource(R.string.delete) - - val showDialog = remember { mutableStateOf(false) } - val positiveAction = remember { mutableStateOf({}) } - - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = String.format( - stringResource(R.string.delete_chats_alert), - aor.substringAfter(":") - ), - positiveButtonText = stringResource(R.string.delete), - onPositiveClicked = positiveAction.value, - negativeButtonText = stringResource(R.string.cancel), - ) - - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontSize = 22.sp, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton( - onClick = { expanded = !expanded } - ) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Menu", - tint = LocalCustomColors.current.light - ) - } - CustomElements.DropdownMenu( - expanded, - { expanded = false }, - listOf(delete), - onItemClick = { selectedItem -> - expanded = false - when (selectedItem) { - delete -> { - positiveAction.value = { - Message.clearMessagesOfAor(aor) - Message.save() - _uaMessages.value = listOf() - account.unreadMessages = false - } - showDialog.value = true - } - } - } - ) - } - ) - } - - @Composable - fun ChatsContent(ctx: Context, contentPadding: PaddingValues) { - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding), - verticalArrangement = Arrangement.Top - ) { - Account(account) - Chats(ctx, account) - } - } - - @Composable - fun Account(account: Account) { - val headerText = stringResource(R.string.account) + " " + - if (account.nickName.value != "") - account.nickName.value - else - aor.split(":")[1] - Text( - text = headerText, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp), - color = LocalCustomColors.current.itemText, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center - ) - } - - @Composable - fun Chats(ctx: Context, account: Account) { - - val showDialog = remember { mutableStateOf(false) } - val dialogMessage = remember { mutableStateOf("") } - val positiveButtonText = remember { mutableStateOf("") } - val positiveAction = remember { mutableStateOf({}) } - val neutralButtonText = remember { mutableStateOf("") } - val neutralAction = remember { mutableStateOf({}) } - - if (showDialog.value) - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = dialogMessage.value, - positiveButtonText = positiveButtonText.value, - onPositiveClicked = positiveAction.value, - neutralButtonText = neutralButtonText.value, - onNeutralClicked = neutralAction.value, - negativeButtonText = stringResource(R.string.cancel) - ) - - val lazyListState = rememberLazyListState() - - LazyColumn( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .padding(start = 8.dp, end = 4.dp) - .verticalScrollbar( - state = lazyListState, - width = 4.dp, - color = LocalCustomColors.current.gray - ) - .background(LocalCustomColors.current.background), - reverseLayout = true, - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(items = uaMessages, key = { message -> message.timeStamp }) { message -> - Row(verticalAlignment = Alignment.CenterVertically) { - when (val contact = Contact.findContact(message.peerUri)) { - is Contact.BaresipContact -> { - val avatarImage = contact.avatarImage - if (avatarImage != null) - ImageAvatar(avatarImage) - else - TextAvatar(contact.name, contact.color) - } - - is Contact.AndroidContact -> { - val thumbNailUri = contact.thumbnailUri - if (thumbNailUri != null) - AsyncImage( - model = thumbNailUri, - contentDescription = "Avatar", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - ) - else - TextAvatar(contact.name, contact.color) - } - - null -> { - val avatarImage = BitmapFactory - .decodeResource(ctx.resources, R.drawable.person_image) - ImageAvatar(avatarImage) - } - } - Spacer(modifier = Modifier.width(6.dp)) - CustomElements.Button( - onClick = { - val i = Intent(ctx, ChatActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putString("peer", message.peerUri) - i.putExtras(b) - chatRequest.launch(i) - }, - onLongClick = { - val peer = Utils.friendlyUri(ctx, message.peerUri, account) - val contactExists = - Contact.nameExists(peer, BaresipService.contacts, false) - if (contactExists) { - dialogMessage.value = String.format( - getString(R.string.short_chat_question), - peer - ) - positiveButtonText.value = getString(R.string.delete) - positiveAction.value = { - Message.deleteAorPeerMessages(aor, message.peerUri) - _uaMessages.value = uaMessages(aor) - } - neutralButtonText.value = "" - } else { - dialogMessage.value = String.format( - getString(R.string.long_chat_question), - peer - ) - positiveButtonText.value = getString(R.string.add_contact) - positiveAction.value = { - val i = Intent(ctx, BaresipContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", message.peerUri) - i.putExtras(b) - startActivity(i) - } - neutralButtonText.value = getString(R.string.delete) - neutralAction.value = { - Message.deleteAorPeerMessages(aor, message.peerUri) - } - } - showDialog.value = true - }, - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(end = 6.dp), - shape = if (message.direction == MESSAGE_DOWN) - RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp) - else - RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp), - color = - if (message.direction == MESSAGE_DOWN) { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.secondaryDark - else - LocalCustomColors.current.secondaryLight - } else { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.primaryDark - else - LocalCustomColors.current.primaryLight - }, - ) { - val peer = Utils.friendlyUri(ctx, message.peerUri, account) - val cal = GregorianCalendar() - cal.timeInMillis = message.timeStamp - val fmt: DateFormat = if (isToday(message.timeStamp)) - DateFormat.getTimeInstance(DateFormat.SHORT) - else - DateFormat.getDateInstance(DateFormat.SHORT) - val info = fmt.format(cal.time) - Column { - Row { - val textColor = - if (message.direction == MESSAGE_DOWN) { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.secondaryLight - else - LocalCustomColors.current.secondaryDark - } else { - if (BaresipService.darkTheme.value) - LocalCustomColors.current.primaryLight - else - LocalCustomColors.current.primaryDark - } - Text(text = peer, color = textColor, fontSize = 12.sp) - Spacer(modifier = Modifier.weight(1f)) - Text(text = info, color = textColor, fontSize = 12.sp) - } - Row { - BasicText( - text = message.message, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TextStyle( - color = LocalCustomColors.current.itemText, - fontWeight = if (message.direction == MESSAGE_DOWN && message.new) - FontWeight.Bold else FontWeight.Normal, - fontSize = 16.sp - ) - ) - } - } - } - } - } - } - } - - @Composable - fun NewChatPeer() { - - val showDialog = remember { mutableStateOf(false) } - val items = remember { mutableStateOf(listOf()) } - val itemAction = remember { mutableStateOf<(Int) -> Unit>({ _ -> run {} }) } - - SelectableAlertDialog( - openDialog = showDialog, - title = stringResource(R.string.choose_destination_uri), - items = items.value, - onItemClicked = itemAction.value, - neutralButtonText = stringResource(R.string.cancel), - onNeutralClicked = {} - ) - - val suggestions by remember { contactNames } - var filteredSuggestions by remember { mutableStateOf(suggestions) } - var showSuggestions by remember { mutableStateOf(false) } - val lazyListState = rememberLazyListState() - val focusManager = LocalFocusManager.current - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - var newPeer by remember { mutableStateOf("") } - Column( - horizontalAlignment = Alignment.Start, - modifier = Modifier.weight(1f) - ) { - if (showSuggestions && filteredSuggestions.isNotEmpty()) { - Column( - modifier = Modifier - .shadow(8.dp, RoundedCornerShape(8.dp)) - .background( - LocalCustomColors.current.grayLight, - shape = RoundedCornerShape(8.dp) - ) - .animateContentSize() - ) { - Box(modifier = Modifier - .fillMaxWidth() - .heightIn(max = 150.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 180.dp) - .verticalScrollbar( - state = lazyListState, - color = LocalCustomColors.current.gray - ), - horizontalAlignment = Alignment.Start, - state = lazyListState - ) { - items( - items = filteredSuggestions, - key = { suggestion -> suggestion } - ) { suggestion -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - newPeer = suggestion - showSuggestions = false - } - .padding(12.dp) - ) { - Text( - text = suggestion, - modifier = Modifier.fillMaxWidth(), - color = LocalCustomColors.current.grayDark, - fontSize = 18.sp - ) - } - } - } - } - } - Spacer(modifier = Modifier.height(8.dp)) - } - OutlinedTextField( - value = newPeer, - placeholder = { - Text(stringResource(R.string.new_chat_peer)) - }, - onValueChange = { - newPeer = it - showSuggestions = newPeer.length > 2 - filteredSuggestions = if (it.isEmpty()) { - suggestions - } else { - suggestions.filter { suggestion -> - newPeer.length > 2 && suggestion.startsWith( - newPeer, - ignoreCase = true - ) - } - } - }, - modifier = Modifier - .padding(end = 6.dp) - .fillMaxWidth(), - singleLine = true, - trailingIcon = { - if (newPeer.isNotEmpty()) - Icon( - Icons.Outlined.Clear, - contentDescription = null, - modifier = Modifier - .clickable { - if (showSuggestions) - showSuggestions = false - else - newPeer = "" - } - ) - }, - label = { - LabelText(stringResource(R.string.new_chat_peer)) - }, - textStyle = TextStyle( - fontSize = 18.sp, - color = LocalCustomColors.current.itemText - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ) - ) - } - Spacer(Modifier.width(4.dp)) - SmallFloatingActionButton( - modifier = Modifier.padding(end = 4.dp), - onClick = { - showSuggestions = false - val peerText = newPeer.trim() - if (peerText.isNotEmpty()) { - val uris = Contact.contactUris(peerText) - if (uris.isEmpty()) - makeChat(peerText) - else if (uris.size == 1) - makeChat(uris[0]) - else { - items.value = uris - itemAction.value = { index -> - makeChat(uris[index]) - } - showDialog.value = true - } - } - newPeer = "" - focusManager.clearFocus() - }, - containerColor = LocalCustomColors.current.accent, - contentColor = LocalCustomColors.current.background - ) { - Icon( - imageVector = Icons.Filled.Add, - modifier = Modifier.size(36.dp), - contentDescription = stringResource(R.string.add) - ) - } - } - } - - private fun makeChat(chatPeer: String) { - val peerUri = if (Utils.isTelNumber(chatPeer)) - "tel:$chatPeer" - else - chatPeer - val uri = if (Utils.isTelUri(peerUri)) { - if (account.telProvider == "") { - alertTitle.value = getString(R.string.notice) - alertMessage.value = - String.format(getString(R.string.no_telephony_provider), account.aor) - showAlert.value = true - "" - } else - Utils.telToSip(peerUri, account) - } else - Utils.uriComplete(peerUri, aor) - if (alertMessage.value.isEmpty()) { - if (!Utils.checkUri(uri)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), uri) - showAlert.value = true - } - else { - val i = Intent(this@ChatsActivity, ChatActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putString("peer", uri) - i.putExtras(b) - chatRequest.launch(i) - } - } - } - - override fun onPause() { - MainActivity.activityAor = aor - super.onPause() - } - - override fun onResume() { - super.onResume() - _uaMessages.value = uaMessages(aor) - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("chats,$aor") - returnResult() - } - - private fun returnResult() { - setResult(RESULT_CANCELED, Intent()) - finish() - } - - private fun uaMessages(aor: String) : List { - val res = mutableListOf() - account.unreadMessages = false - for (m in BaresipService.messages.reversed()) { - if (m.aor != aor) continue - var found = false - for (r in res) - if (r.peerUri == m.peerUri) { - found = true - break - } - if (!found) { - res.add(0, m) - if (m.new) - account.unreadMessages = true - } - } - return res.toList() - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt new file mode 100644 index 00000000..f6f760c8 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt @@ -0,0 +1,684 @@ +package com.tutpro.baresip + +import android.content.Context +import android.graphics.BitmapFactory +import android.text.format.DateUtils.isToday +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +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.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +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.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import com.tutpro.baresip.BaresipService.Companion.contactNames +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.DropdownMenu +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.SelectableAlertDialog +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.text.DateFormat +import java.util.GregorianCalendar + +fun NavGraphBuilder.chatsScreenRoute(navController: NavController) { + composable( + route = "chats/{aor}", + arguments = listOf(navArgument("aor") { type = NavType.StringType }) + ) { backStackEntry -> + val aor = backStackEntry.arguments?.getString("aor")!! + ChatsScreen(navController, aor) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatsScreen(navController: NavController, aor: String) { + + val account = Account.ofAor(aor)!! + val ctx = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val uaMessages: MutableState> = remember { mutableStateOf(emptyList()) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + Log.d(TAG, "Resumed to ChatsScreen for AOR: $aor") + uaMessages.value = loadMessages(account) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + var areMessagesLoaded by remember { mutableStateOf(false) } + LaunchedEffect(aor) { + uaMessages.value = loadMessages(account) + areMessagesLoaded = true + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { + TopAppBar(navController, account, uaMessages) + } + }, + bottomBar = { NewChatPeer(ctx, navController, account) }, + content = { contentPadding -> + if (areMessagesLoaded) + ChatsContent(LocalContext.current, navController, contentPadding, account, uaMessages) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + navController: NavController, + account: Account, + uaMessages: MutableState> +) { + + var menuExpanded by remember { mutableStateOf(false) } + val delete = stringResource(R.string.delete) + val showDialog = remember { mutableStateOf(false) } + val positiveAction = remember { mutableStateOf({}) } + + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = String.format( + stringResource(R.string.delete_chats_alert), + if (account.nickName.value != "") + account.nickName.value + else + account.aor.substringAfter(":") + ), + positiveButtonText = stringResource(R.string.delete), + onPositiveClicked = positiveAction.value, + negativeButtonText = stringResource(R.string.cancel), + ) + + TopAppBar( + title = { + Text( + text = stringResource(R.string.chats), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = LocalCustomColors.current.light + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + actions = { + IconButton( + onClick = { menuExpanded = !menuExpanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + tint = LocalCustomColors.current.light + ) + } + DropdownMenu ( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + items = listOf(delete), + onItemClick = { selectedItem -> + menuExpanded = false + when (selectedItem) { + delete -> { + positiveAction.value = { + Message.clearMessagesOfAor(account.aor) + Message.save() + uaMessages.value = listOf() + account.unreadMessages = false + } + showDialog.value = true + } + } + } + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ) + ) +} + +@Composable +private fun ChatsContent( + ctx: Context, + navController: NavController, + contentPadding: PaddingValues, + account: Account, + uaMessages: MutableState> +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), + verticalArrangement = Arrangement.Top + ) { + Account(account) + Chats(ctx, navController, account, uaMessages) + } +} + +@Composable +private fun Account(account: Account) { + val headerText = stringResource(R.string.account) + " " + + if (account.nickName.value != "") + account.nickName.value + else + account.aor.substringAfter(":") + Text( + text = headerText, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp), + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) +} + +@Composable +private fun Chats( + ctx: Context, + navController: NavController, + account: Account, + uaMessages: MutableState> +) { + val aor = account.aor + + val showDialog = remember { mutableStateOf(false) } + val dialogMessage = remember { mutableStateOf("") } + val positiveButtonText = remember { mutableStateOf("") } + val positiveAction = remember { mutableStateOf({}) } + val neutralButtonText = remember { mutableStateOf("") } + val neutralAction = remember { mutableStateOf({}) } + + if (showDialog.value) + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = dialogMessage.value, + positiveButtonText = positiveButtonText.value, + onPositiveClicked = positiveAction.value, + neutralButtonText = neutralButtonText.value, + onNeutralClicked = neutralAction.value, + negativeButtonText = stringResource(R.string.cancel) + ) + + val lazyListState = rememberLazyListState() + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 4.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ) + .background(LocalCustomColors.current.background), + reverseLayout = true, + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(items = uaMessages.value, key = { message -> message.timeStamp }) { message -> + Row(verticalAlignment = Alignment.CenterVertically) { + when (val contact = Contact.findContact(message.peerUri)) { + is Contact.BaresipContact -> { + val avatarImage = contact.avatarImage + if (avatarImage != null) + CustomElements.ImageAvatar(avatarImage) + else + CustomElements.TextAvatar(contact.name, contact.color) + } + + is Contact.AndroidContact -> { + val thumbNailUri = contact.thumbnailUri + if (thumbNailUri != null) + AsyncImage( + model = thumbNailUri, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + else + CustomElements.TextAvatar(contact.name, contact.color) + } + + null -> { + val avatarImage = BitmapFactory + .decodeResource(ctx.resources, R.drawable.person_image) + CustomElements.ImageAvatar(avatarImage) + } + } + Spacer(modifier = Modifier.width(6.dp)) + val buttonShape = if (message.direction == MESSAGE_DOWN) { + RoundedCornerShape(50.dp, 20.dp, 20.dp, 10.dp) + } else { + RoundedCornerShape(20.dp, 10.dp, 50.dp, 20.dp) + } + val borderStroke = if (account.unreadMessages && Message.unreadMessagesFromPeer(aor, message.peerUri)) { + BorderStroke(width = 2.dp, color = LocalCustomColors.current.alert) + } else { + null + } + CustomElements.Button( + onClick = { + navController.navigate("chat/${aor}/${message.peerUri}") + }, + onLongClick = { + val peer = Utils.friendlyUri(ctx, message.peerUri, account) + val contactExists = + Contact.nameExists(peer, BaresipService.contacts, false) + if (contactExists) { + dialogMessage.value = String.format( + ctx.getString(R.string.short_chat_question), + peer + ) + positiveButtonText.value = ctx.getString(R.string.delete) + positiveAction.value = { + Message.deleteAorPeerMessages(aor, message.peerUri) + uaMessages.value = loadMessages(account) + } + neutralButtonText.value = "" + } else { + dialogMessage.value = + String.format(ctx.getString(R.string.long_chat_question), peer) + positiveButtonText.value = ctx.getString(R.string.add_contact) + positiveAction.value = { + navController.navigate("baresip_contact/${message.peerUri}/new") + } + neutralButtonText.value = ctx.getString(R.string.delete) + neutralAction.value = { + Message.deleteAorPeerMessages(aor, message.peerUri) + } + } + showDialog.value = true + }, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(end = 6.dp), + shape = buttonShape, + border = borderStroke, + color = + if (message.direction == MESSAGE_DOWN) { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.secondaryDark + else + LocalCustomColors.current.secondaryLight + } else { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.primaryDark + else + LocalCustomColors.current.primaryLight + }, + ) { + val peer = Utils.friendlyUri(ctx, message.peerUri, account) + val cal = GregorianCalendar() + cal.timeInMillis = message.timeStamp + val fmt: DateFormat = if (isToday(message.timeStamp)) + DateFormat.getTimeInstance(DateFormat.SHORT) + else + DateFormat.getDateInstance(DateFormat.SHORT) + val info = fmt.format(cal.time) + Column { + Row { + val textColor = + if (message.direction == MESSAGE_DOWN) { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.secondaryLight + else + LocalCustomColors.current.secondaryDark + } else { + if (BaresipService.darkTheme.value) + LocalCustomColors.current.primaryLight + else + LocalCustomColors.current.primaryDark + } + Text(text = peer, color = textColor, fontSize = 12.sp) + Spacer(modifier = Modifier.weight(1f)) + Text(text = info, color = textColor, fontSize = 12.sp) + } + Row { + BasicText( + text = message.message, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + color = LocalCustomColors.current.itemText, + fontWeight = if (message.direction == MESSAGE_DOWN && message.new) + FontWeight.Bold else FontWeight.Normal, + fontSize = 16.sp + ) + ) + } + } + } + } + } + } +} + +@Composable +private fun NewChatPeer(ctx: Context, navController: NavController, account: Account) { + + val alertTitle = remember { mutableStateOf("") } + val alertMessage = remember { mutableStateOf("") } + val showAlert = remember { mutableStateOf(false) } + + fun makeChat(ctx: Context, navController: NavController, account: Account, chatPeer: String) { + val peerUri = if (Utils.isTelNumber(chatPeer)) + "tel:$chatPeer" + else + chatPeer + val uri = if (Utils.isTelUri(peerUri)) { + if (account.telProvider == "") { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = + String.format(ctx.getString(R.string.no_telephony_provider), account.aor) + showAlert.value = true + "" + } else + Utils.telToSip(peerUri, account) + } else + Utils.uriComplete(peerUri, account.aor) + if (alertMessage.value.isEmpty()) { + if (!Utils.checkUri(uri)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri) + showAlert.value = true + } + else + navController.navigate("chat/${account.aor}/${uri}") + } + } + + if (showAlert.value) { + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + } + + val showDialog = remember { mutableStateOf(false) } + val items = remember { mutableStateOf(listOf()) } + val itemAction = remember { mutableStateOf<(Int) -> Unit>({ _ -> run {} }) } + + SelectableAlertDialog( + openDialog = showDialog, + title = stringResource(R.string.choose_destination_uri), + items = items.value, + onItemClicked = itemAction.value, + neutralButtonText = stringResource(R.string.cancel), + onNeutralClicked = {} + ) + + val suggestions by remember { contactNames } + var filteredSuggestions by remember { mutableStateOf(suggestions) } + var showSuggestions by remember { mutableStateOf(false) } + val lazyListState = rememberLazyListState() + val focusManager = LocalFocusManager.current + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var newPeer by remember { mutableStateOf("") } + Column( + horizontalAlignment = Alignment.Start, + modifier = Modifier.weight(1f) + ) { + if (showSuggestions && filteredSuggestions.isNotEmpty()) { + Column( + modifier = Modifier + .shadow(8.dp, RoundedCornerShape(8.dp)) + .background( + LocalCustomColors.current.grayLight, + shape = RoundedCornerShape(8.dp) + ) + .animateContentSize() + ) { + Box(modifier = Modifier + .fillMaxWidth() + .heightIn(max = 150.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .verticalScrollbar( + state = lazyListState, + color = LocalCustomColors.current.gray + ), + horizontalAlignment = Alignment.Start, + state = lazyListState + ) { + items( + items = filteredSuggestions, + key = { suggestion -> suggestion } + ) { suggestion -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + newPeer = suggestion + showSuggestions = false + } + .padding(12.dp) + ) { + Text( + text = suggestion, + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColors.current.grayDark, + fontSize = 18.sp + ) + } + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + OutlinedTextField( + value = newPeer, + placeholder = { Text(stringResource(R.string.new_chat_peer)) }, + onValueChange = { + newPeer = it + showSuggestions = newPeer.length > 2 + filteredSuggestions = if (it.isEmpty()) { + suggestions + } else { + suggestions.filter { suggestion -> + newPeer.length > 2 && suggestion.startsWith( + newPeer, + ignoreCase = true + ) + } + } + }, + modifier = Modifier + .padding(end = 6.dp) + .fillMaxWidth(), + singleLine = true, + trailingIcon = { + if (newPeer.isNotEmpty()) + Icon( + Icons.Outlined.Clear, + contentDescription = null, + modifier = Modifier + .clickable { + if (showSuggestions) + showSuggestions = false + else + newPeer = "" + } + ) + }, + label = { + LabelText(stringResource(R.string.new_chat_peer)) + }, + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ) + ) + } + Spacer(Modifier.width(4.dp)) + SmallFloatingActionButton( + modifier = Modifier.padding(end = 4.dp), + onClick = { + showSuggestions = false + val peerText = newPeer.trim() + if (peerText.isNotEmpty()) { + val uris = Contact.contactUris(peerText) + if (uris.isEmpty()) + makeChat(ctx, navController, account, peerText) + else if (uris.size == 1) + makeChat(ctx, navController, account, uris[0]) + else { + items.value = uris + itemAction.value = { index -> + makeChat(ctx, navController, account, uris[index]) + } + showDialog.value = true + } + } + newPeer = "" + focusManager.clearFocus() + }, + containerColor = LocalCustomColors.current.accent, + contentColor = LocalCustomColors.current.background + ) { + Icon( + imageVector = Icons.Filled.Add, + modifier = Modifier.size(36.dp), + contentDescription = stringResource(R.string.add) + ) + } + } +} + +private fun loadMessages(account: Account) : List { + val res = mutableListOf() + account.unreadMessages = false + for (m in BaresipService.messages.reversed()) { + if (m.aor != account.aor) continue + var found = false + for (r in res) + if (r.peerUri == m.peerUri) { + found = true + break + } + if (!found) { + res.add(0, m) + if (m.new) + account.unreadMessages = true + } + } + return res.toList() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt deleted file mode 100644 index efd0390c..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/CodecsActivity.kt +++ /dev/null @@ -1,384 +0,0 @@ -package com.tutpro.baresip - -import android.os.Build -import android.os.Bundle -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -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.safeDrawingPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.verticalScrollbar - -class CodecsActivity : ComponentActivity() { - - private lateinit var acc: Account - private lateinit var ua: UserAgent - - private lateinit var allCodecs: List - private lateinit var accCodecs: List - private lateinit var codecs: SnapshotStateList - - private var aor = "" - private var media = "" - private var title = "" - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - aor = intent.getStringExtra("aor")!! - media = intent.getStringExtra("media")!! - - Utils.addActivity("codecs,$aor,$media") - - ua = UserAgent.ofAor(aor)!! - acc = ua.account - - if (media == "audio") { - title = getString(R.string.audio_codecs) - allCodecs = ArrayList(Api.audio_codecs().split(",")) - accCodecs = acc.audioCodec - } else { - title = getString(R.string.video_codecs) - allCodecs = ArrayList(Api.video_codecs().split(",").distinct()) - accCodecs = acc.videoCodec - } - - val currentCodecs = mutableListOf() - for (codec in accCodecs) - currentCodecs.add(Codec(codec, mutableStateOf(true))) - for (codec in allCodecs) - if (codec !in accCodecs) - currentCodecs.add(Codec(codec, mutableStateOf(false))) - - codecs = mutableStateListOf().apply { addAll(currentCodecs) } - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - CodecsScreen { goBack() } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun CodecsScreen(navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.codecs), - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton(onClick = { - updateCodecs() - }) { - Icon( - imageVector = Icons.Filled.Check, - tint = LocalCustomColors.current.light, - contentDescription = "Check" - ) - } - } - ) - }, - content = { contentPadding -> - CodecsContent(contentPadding) - }, - ) - } - - @Composable - fun CodecsContent(contentPadding: PaddingValues) { - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .background(LocalCustomColors.current.background) - .padding(contentPadding) - .padding(bottom = 16.dp), - ) { - Title() - Codecs(codecs = codecs, onCodecsChange = { updatedCodecs -> - onCodecsChange(updatedCodecs) - }) - } - } - - private fun onCodecsChange(updatedCodecs: List) { - codecs.clear() - codecs.addAll(updatedCodecs) - } - - @Composable - fun Title() { - Text( - text = title, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .clickable { - if (media == "audio") { - alertTitle.value = getString(R.string.audio_codecs) - alertMessage.value = getString(R.string.audio_codecs_help) - } - else { - alertTitle.value = getString(R.string.video_codecs) - alertMessage.value = getString(R.string.video_codecs_help) - } - showAlert.value = true - }, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = LocalCustomColors.current.itemText, - textAlign = TextAlign.Center - ) - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun Codecs(codecs: SnapshotStateList, onCodecsChange: (List) -> Unit) { - - val draggableState = rememberDraggableListState( - onMove = { fromIndex, toIndex -> - codecs.add(toIndex, codecs.removeAt(fromIndex)) - onCodecsChange(codecs.toList()) - } - ) - - LazyColumn( - modifier = Modifier - .padding(end = 4.dp) - .verticalScrollbar( - state = draggableState.listState, - width = 4.dp, - color = LocalCustomColors.current.gray - ), - state = draggableState.listState, - contentPadding = PaddingValues(start = 12.dp, end = 12.dp), - //verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - draggableItems( - state = draggableState, - items = codecs, - key = { item -> item.name } - ) { item, isDragging -> - ListItem( - colors = ListItemDefaults.colors( - containerColor = if (isDragging) - LocalCustomColors.current.grayLight - else - LocalCustomColors.current.background - ), - headlineContent = { - Text(text = item.name, - modifier = Modifier.fillMaxWidth() - .alpha(if (item.enabled.value) 1.0f else 0.5f) - .padding(start = 6.dp) - .combinedClickable( - onClick = {}, - onLongClick = { - item.enabled.value = !item.enabled.value - if (item.enabled.value) { - val index = codecs.indexOf(item) - codecs.removeAt(index) - codecs.add(0, item) - } - else { - val index = codecs.indexOf(item) - codecs.removeAt(index) - codecs.add(item) - } - onCodecsChange(codecs.toList()) - } - ) - ) - }, - trailingContent = { - Icon( - modifier = Modifier.dragHandle( - state = draggableState, - key = item.name - ), - imageVector =ImageVector.vectorResource(R.drawable.reorder), - contentDescription = null - ) - }, - ) - if (codecs.indexOf(item) > 0) - HorizontalDivider( - color = LocalCustomColors.current.gray, - modifier = Modifier.padding(horizontal = 12.dp), - thickness = 1.dp - ) - } - } - } - - private fun updateCodecs() { - - var save = false - val newCodecs = ArrayList() - - for (codec in codecs) - if (codec.enabled.value) - newCodecs.add(codec.name) - - val codecList = Utils.implode(newCodecs, ",") - - if (media == "audio") - if (newCodecs != acc.audioCodec) { - if (Api.account_set_audio_codecs(acc.accp, codecList) == 0) { - Log.d(TAG, "New audio codecs '$codecList'") - acc.audioCodec = newCodecs - save = true - } else { - Log.e(TAG, "Setting of audio codecs '$codecList' failed") - } - } - - if (media == "video") - if (newCodecs != acc.videoCodec) { - if (Api.account_set_video_codecs(acc.accp, codecList) == 0) { - Log.d(TAG, "New video codecs '$codecs'") - acc.videoCodec = newCodecs - save = true - } else { - Log.e(TAG, "Setting of video codecs '$codecs' failed") - } - } - - if (save) - AccountsActivity.saveAccounts() - - BaresipService.activities.remove("codecs,$aor,$media") - finish() - } - - private fun goBack() { - BaresipService.activities.remove("codecs,$aor,$media") - finish() - } - - override fun onPause() { - MainActivity.activityAor = aor - super.onPause() - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/CodecsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CodecsScreen.kt new file mode 100644 index 00000000..63b2c36d --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/CodecsScreen.kt @@ -0,0 +1,301 @@ +package com.tutpro.baresip + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.verticalScrollbar + +fun NavGraphBuilder.codecsScreenRoute(navController: NavController) { + composable( + route = "codecs/{aor}/{media}", + arguments = listOf( + navArgument("aor") { type = NavType.StringType }, + navArgument("media") { type = NavType.StringType }) + ) { backStackEntry -> + val aor = backStackEntry.arguments?.getString("aor")!! + val media = backStackEntry.arguments?.getString("media")!! + val account = UserAgent.ofAor(aor)?.account!! + CodecsScreen( + onBack = { navController.popBackStack() }, + checkOnClick = { updatedCodecs -> + val enabledCodecNames = updatedCodecs.filter { it.enabled.value }.map { it.name } + val codecList = Utils.implode(enabledCodecNames, ",") + Log.d(TAG, "Saving codecs for ${account.aor} (${media}): $codecList") + val success = if (media == "audio") + Api.account_set_audio_codecs(account.accp, codecList) + else + Api.account_set_video_codecs(account.accp, codecList) + if (success == 0) { + if (media == "audio") + account.audioCodec = enabledCodecNames as ArrayList + else + account.videoCodec = enabledCodecNames as ArrayList + Account.saveAccounts() + Log.d("CodecsSave", "Codecs saved successfully.") + } + else + Log.e(TAG, "Failed to set $aor codecs.") + navController.popBackStack() + }, + aor = aor, + media = media + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CodecsScreen( + onBack: () -> Unit, + checkOnClick: (List) -> Unit, + aor: String, + media: String +) { + val ua = UserAgent.ofAor(aor)!! + val acc = ua.account + var currentCodecsState by remember { mutableStateOf>(emptyList()) } + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = if (media == "audio") + stringResource(R.string.audio_codecs) + else + stringResource(R.string.video_codecs), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + actions = { + IconButton(onClick = { + checkOnClick(currentCodecsState) + }) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + } + ) + } + }, + content = { contentPadding -> + CodecsContent( + contentPadding, + acc, + media, + onUpdateCodecs = { updatedCodecs -> currentCodecsState = updatedCodecs } + ) + }, + ) +} + +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + + +@Composable +private fun CodecsContent( + contentPadding: PaddingValues, + acc: Account, + media: String, + onUpdateCodecs: (List) -> Unit +) { + val codecs = remember { mutableStateListOf() } + + LaunchedEffect(acc, media) { + val allCodecs: List = if (media == "audio") { + Api.audio_codecs().split(",") + } else { + Api.video_codecs().split(",").distinct() + } + val accCodecs: List = if (media == "audio") { + acc.audioCodec + } else { + acc.videoCodec + } + val currentCodecs = mutableListOf() + for (codec in accCodecs) + currentCodecs.add(Codec(codec, mutableStateOf(true))) + for (codec in allCodecs) + if (codec !in accCodecs) + currentCodecs.add(Codec(codec, mutableStateOf(false))) + codecs.clear() + codecs.addAll(currentCodecs) + } + + if (showAlert.value) { + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(bottom = 16.dp), + ) { + Codecs( + codecs = codecs, + onUpdateCodecs = onUpdateCodecs + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Codecs(codecs: SnapshotStateList, onUpdateCodecs: (List) -> Unit) { + + val draggableState = rememberDraggableListState( + onMove = { fromIndex, toIndex -> + codecs.add(toIndex, codecs.removeAt(fromIndex)) + onUpdateCodecs(codecs.toList()) + } + ) + + LazyColumn( + modifier = Modifier + .padding(end = 4.dp) + .verticalScrollbar( + state = draggableState.listState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + state = draggableState.listState, + contentPadding = PaddingValues(start = 12.dp, end = 12.dp), + //verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + draggableItems( + state = draggableState, + items = codecs, + key = { item -> item.name } + ) { item, isDragging -> + ListItem( + colors = ListItemDefaults.colors( + containerColor = if (isDragging) + LocalCustomColors.current.grayLight + else + LocalCustomColors.current.background + ), + headlineContent = { + Text(text = item.name, + modifier = Modifier + .fillMaxWidth() + .alpha(if (item.enabled.value) 1.0f else 0.5f) + .padding(start = 6.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + item.enabled.value = !item.enabled.value + if (item.enabled.value) { + val index = codecs.indexOf(item) + codecs.removeAt(index) + codecs.add(0, item) + } else { + val index = codecs.indexOf(item) + codecs.removeAt(index) + codecs.add(item) + } + onUpdateCodecs(codecs.toList()) + } + ) + ) + }, + trailingContent = { + Icon( + modifier = Modifier.dragHandle( + state = draggableState, + key = item.name + ), + imageVector =ImageVector.vectorResource(R.drawable.reorder), + contentDescription = null + ) + }, + ) + if (codecs.indexOf(item) > 0) + HorizontalDivider( + color = LocalCustomColors.current.gray, + modifier = Modifier.padding(horizontal = 12.dp), + thickness = 1.dp + ) + } + } +} + + diff --git a/app/src/main/kotlin/com/tutpro/baresip/Config.kt b/app/src/main/kotlin/com/tutpro/baresip/Config.kt index 4be7d1e7..1874d8fa 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Config.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Config.kt @@ -10,6 +10,7 @@ import java.nio.charset.StandardCharsets object Config { private val configPath = BaresipService.filesPath + "/config" + val audioModules = listOf("opus", "amr", "g722", "g7221", "g726", "g729", "codec2", "g711") private lateinit var config: String private lateinit var previousConfig: String private lateinit var previousLines: List @@ -18,7 +19,7 @@ object Config { config = ctx.assets.open("config.static").bufferedReader().use { it.readText() } if (!File(configPath).exists()) { - for (module in AudioActivity.audioModules) + for (module in audioModules) config = "${config}module ${module}.so\n" previousConfig = config } else { @@ -149,7 +150,7 @@ object Config { } val previousModules = previousVariables("module") - for (module in AudioActivity.audioModules) + for (module in audioModules) if ("${module}.so" in previousModules) config = "${config}module ${module}.so\n" diff --git a/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt deleted file mode 100644 index bb9691da..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ConfigActivity.kt +++ /dev/null @@ -1,1407 +0,0 @@ -package com.tutpro.baresip - -import android.Manifest -import android.app.role.RoleManager -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.media.RingtoneManager -import android.net.Uri -import android.os.Build.VERSION -import android.os.Bundle -import android.os.PowerManager -import android.provider.Settings -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatDelegate -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.fillMaxHeight -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.safeDrawingPadding -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.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.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.Switch -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.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.content.ContextCompat -import androidx.core.net.toUri -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.verticalScrollbar -import com.tutpro.baresip.Utils.copyInputStreamToFile -import java.io.File -import java.io.FileInputStream -import java.util.Locale - -class ConfigActivity : ComponentActivity() { - - private lateinit var baresipService: Intent - private var oldAutoStart = false - private var newAutoStart = false - private var oldDarkTheme = false - private var newDarkTheme = false - private var oldListenAddr = "" - private var newListenAddr = "" - private var oldAddressFamily = "" - private var newAddressFamily = "" - private var oldDnsServers = "" - private var newDnsServers = "" - private var oldTlsCertificateFile = false - private var newTlsCertificateFile = false - private var oldVerifyServer = false - private var newVerifyServer = false - private var oldCaFile = false - private var newCaFile = false - private var oldUserAgent = "" - private var newUserAgent = "" - private var oldRingtoneUri = "" - private var newRingtoneUri = "" - private var oldBatteryOptimizations = false - private var newBatteryOptimizations = false - private var oldDefaultDialer = false - private var newDefaultDialer = false - private var oldContactsMode = "" - private var newContactsMode = "" - private var oldDebug = false - private var newDebug = false - private var oldSipTrace = false - private var newSipTrace = false - - private lateinit var roleManager: RoleManager - private lateinit var powerManager: PowerManager - private lateinit var dialerRoleRequest: ActivityResultLauncher - private lateinit var androidSettingsRequest: ActivityResultLauncher - private lateinit var requestPermissionLauncher: ActivityResultLauncher - private lateinit var requestPermissionsLauncher: ActivityResultLauncher> - - private var save = false - private var restart = false - private var audioRestart = false - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private val dialogTitle = mutableStateOf("") - private val dialogMessage = mutableStateOf("") - private val positiveText = mutableStateOf("") - private val onPositiveClicked = mutableStateOf({}) - private val negativeText = mutableStateOf("") - private val onNegativeClicked = mutableStateOf({}) - private val showDialog = mutableStateOf(false) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - private val certificateRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - val certPath = BaresipService.filesPath + "/cert.pem" - val certFile = File(certPath) - if (it.resultCode == RESULT_OK) { - it.data?.data?.also { uri -> - try { - val inputStream = - applicationContext.contentResolver.openInputStream(uri) - as FileInputStream - certFile.copyInputStreamToFile(inputStream) - inputStream.close() - Config.replaceVariable("sip_certificate", certPath) - save = true - restart = true - } catch (e: Error) { - alertTitle.value = getString(R.string.error) - alertMessage.value = getString(R.string.read_cert_error) + ": " + e.message - showAlert.value = true - newTlsCertificateFile = false - } - } - } - else - newTlsCertificateFile = false - if (!newTlsCertificateFile) - Utils.deleteFile(certFile) - } - - private val caCertsRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - val caCertsFile = File(BaresipService.filesPath + "/ca_certs.crt") - if (it.resultCode == RESULT_OK) - it.data?.data?.also { uri -> - try { - val inputStream = - applicationContext.contentResolver.openInputStream(uri) - as FileInputStream - caCertsFile.copyInputStreamToFile(inputStream) - inputStream.close() - restart = true - } catch (e: Error) { - alertTitle.value = getString(R.string.error) - alertMessage.value = getString(R.string.read_ca_certs_error) + ": " + e.message - showAlert.value = true - newCaFile = false - } - } - else - newCaFile = false - if (!newCaFile) - caCertsFile.delete() - } - - private val audioRequest = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - audioRestart = it.resultCode == RESULT_OK - } - - override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - val title = getString(R.string.configuration) - - Utils.addActivity("config") - - baresipService = Intent(this@ConfigActivity, BaresipService::class.java) - - oldAutoStart = Config.variable("auto_start") == "yes" - if (oldAutoStart && !isAppearOnTopPermissionGranted(this)) { - Config.replaceVariable("auto_start", "no") - oldAutoStart = false - save = true - } - newAutoStart = oldAutoStart - - oldListenAddr = Config.variable("sip_listen") - - oldAddressFamily = Config.variable("net_af").lowercase() - newAddressFamily = oldAddressFamily - - val dynamicDns = Config.variable("dyn_dns") - if (dynamicDns == "yes") { - oldDnsServers = "" - } else { - val servers = Config.variables("dns_server") - var serverList = "" - for (server in servers) - serverList += ", $server" - oldDnsServers = serverList.trimStart(',').trimStart(' ') - } - - val certFile = File(BaresipService.filesPath + "/cert.pem") - oldTlsCertificateFile = certFile.exists() - - oldVerifyServer = Config.variable("sip_verify_server") == "yes" - newVerifyServer = oldVerifyServer - - val caCertsFile = File(BaresipService.filesPath + "/ca_certs.crt") - oldCaFile = caCertsFile.exists() - - oldUserAgent = Config.variable("user_agent") - newUserAgent = oldUserAgent - - powerManager = getSystemService(POWER_SERVICE) as PowerManager - oldBatteryOptimizations = powerManager - .isIgnoringBatteryOptimizations(packageName) == false - newBatteryOptimizations = oldBatteryOptimizations - - androidSettingsRequest = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - newBatteryOptimizations = powerManager - .isIgnoringBatteryOptimizations(packageName) == false - } - - dialerRoleRequest = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - Log.d(TAG, "dialerRoleRequest succeeded: " + - "${it.resultCode == RESULT_OK}") - if (VERSION.SDK_INT >= 29) - newDefaultDialer = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) - } - - if (VERSION.SDK_INT >= 29) { - roleManager = getSystemService(ROLE_SERVICE) as RoleManager - oldDefaultDialer = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) - } - - oldContactsMode = Config.variable("contacts_mode").lowercase() - newContactsMode = oldContactsMode - - oldDarkTheme = Preferences(applicationContext).displayTheme == - AppCompatDelegate.MODE_NIGHT_YES - newDarkTheme = oldDarkTheme - - oldDebug = Config.variable("log_level") == "0" - newDebug = oldDebug - - oldSipTrace = BaresipService.sipTrace - newSipTrace = oldSipTrace - - requestPermissionsLauncher = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { } - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - ConfigScreen(this, title) { goBack() } - } - } - } - } - - @Composable - fun ConfigScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(title, navigateBack) }, - content = { contentPadding -> - ConfigContent(ctx, contentPadding) - } - ) - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(title: String, navigateBack: () -> Unit) { - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - actions = { - IconButton(onClick = { - checkOnClick() - }) { - Icon( - imageVector = Icons.Filled.Check, - tint = LocalCustomColors.current.light, - contentDescription = "Check" - ) - } - } - ) - } - - @Composable - fun ConfigContent(ctx: Context, contentPadding: PaddingValues) { - - if (showAlert.value) { - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - } - - - if (showDialog.value) - AlertDialog( - showDialog = showDialog, - title = dialogTitle.value, - message = dialogMessage.value, - positiveButtonText = positiveText.value, - onPositiveClicked = onPositiveClicked.value, - negativeButtonText = negativeText.value, - onNegativeClicked = onNegativeClicked.value, - ) - - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(contentPadding) - .padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 8.dp) - .verticalScrollbar(scrollState) - .verticalScroll(state = scrollState), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - StartAutomatically(ctx) - ListenAddress() - AddressFamily() - DnsServers() - TlsCertificateFile(ctx) - VerifyServer() - CaFile(ctx) - UserAgent() - AudioSettings(ctx) - Ringtone() - BatteryOptimizations() - if (VERSION.SDK_INT >= 29) - DefaultDialer() - Contacts(ctx) - DarkTheme() - Debug() - SipTrace() - Reset() - } - } - - @Composable - private fun StartAutomatically(ctx: Context) { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.start_automatically), - modifier = Modifier.weight(1f) - .clickable { - alertTitle.value = getString(R.string.start_automatically) - alertMessage.value = getString(R.string.start_automatically_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var startAutomatically by remember { mutableStateOf(oldAutoStart) } - Switch( - checked = startAutomatically, - onCheckedChange = { - if (it) { - if (!isAppearOnTopPermissionGranted(ctx)) { - dialogTitle.value = getString(R.string.notice) - dialogMessage.value = getString(R.string.appear_on_top_permission) - positiveText.value = getString(R.string.ok) - onPositiveClicked.value = { - val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) - startActivity(intent) - } - negativeText.value = getString(R.string.cancel) - onNegativeClicked.value = { - negativeText.value = "" - } - showDialog.value = true - startAutomatically = false - } - else - startAutomatically = true - } - else - startAutomatically = false - newAutoStart = startAutomatically - } - ) - } - } - - @Composable - private fun ListenAddress() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - var listenAddr by remember { mutableStateOf(oldListenAddr) } - newListenAddr = listenAddr - OutlinedTextField( - value = listenAddr, - placeholder = { Text(stringResource(R.string._0_0_0_0_5060)) }, - onValueChange = { - listenAddr = it - newListenAddr = listenAddr - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.listen_address) - alertMessage.value = getString(R.string.listen_address_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.listen_address)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun AddressFamily() { - Row( - Modifier.fillMaxWidth().padding(top = 12.dp).padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.address_family), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.address_family) - alertMessage.value = getString(R.string.address_family_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { - mutableStateOf(false) - } - val familyNames = listOf("--", "IPv4", "IPv6") - val familyValues = listOf("", "ipv4", "ipv6") - val itemPosition = remember { - mutableIntStateOf(familyValues.indexOf(oldAddressFamily)) - } - Box { - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = familyNames[itemPosition.intValue], - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - familyNames.forEachIndexed { index, family -> - DropdownMenuItem(text = { - Text(text = family) - }, - onClick = { - isDropDownExpanded.value = false - itemPosition.intValue = index - newAddressFamily = familyValues[index] - }) - if (index < 2) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - } - } - } - } - } - - @Composable - private fun DnsServers() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var dnsServers by remember { mutableStateOf(oldDnsServers) } - newDnsServers = dnsServers - OutlinedTextField( - value = dnsServers, - onValueChange = { - dnsServers = it - newDnsServers = dnsServers - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.dns_servers) - alertMessage.value = getString(R.string.dns_servers_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText - ), - label = { LabelText(stringResource(R.string.dns_servers)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun TlsCertificateFile(ctx: Context) { - val showAlertDialog = remember { mutableStateOf(false) } - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.tls_certificate_file), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.tls_certificate_file) - alertMessage.value = getString(R.string.tls_certificate_file_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var tlsCertificateFile by remember { mutableStateOf(oldTlsCertificateFile) } - Switch( - checked = tlsCertificateFile, - onCheckedChange = { - tlsCertificateFile = it - newTlsCertificateFile = tlsCertificateFile - if (it) - if (VERSION.SDK_INT < 29) { - tlsCertificateFile = false - when { - ContextCompat.checkSelfPermission( - ctx, - Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Read External Storage permission granted") - val downloadsPath = Utils.downloadsPath("cert.pem") - val content = Utils.getFileContents(downloadsPath) - if (content == null) { - alertTitle.value = getString(R.string.error) - alertMessage.value = getString(R.string.read_cert_error) - showAlert.value = true - return@Switch - } - val certPath = BaresipService.filesPath + "/cert.pem" - Utils.putFileContents(certPath, content) - Config.replaceVariable("sip_certificate", certPath) - tlsCertificateFile = true - save = true - restart = true - } - shouldShowRequestPermissionRationale(Manifest.permission.READ_EXTERNAL_STORAGE) -> { - showAlertDialog.value = true - } - else -> - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - else - Utils.selectInputFile(certificateRequest) - else { - Config.removeVariable("sip_certificate") - Utils.deleteFile(File(BaresipService.filesPath + "/cert.pem")) - save = true - restart = true - } - } - ) - } - if (showAlertDialog.value) - AlertDialog( - showDialog = showAlertDialog, - title = stringResource(R.string.notice), - message = stringResource(R.string.no_read_permission), - positiveButtonText = stringResource(R.string.ok), - onPositiveClicked = { requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) }, - negativeButtonText = "", - onNegativeClicked = {}, - ) - } - - @Composable - private fun VerifyServer() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.verify_server), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.verify_server) - alertMessage.value = getString(R.string.verify_server_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var verifyServer by remember { mutableStateOf(oldVerifyServer) } - Switch( - checked = verifyServer, - onCheckedChange = { - verifyServer = it - newVerifyServer = verifyServer - } - ) - } - } - - @Composable - private fun CaFile(ctx: Context) { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.tls_ca_file), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.tls_ca_file) - alertMessage.value = getString(R.string.tls_ca_file_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var caFile by remember { mutableStateOf(oldCaFile) } - Switch( - checked = caFile, - onCheckedChange = { - caFile = it - newCaFile = caFile - if (it) { - if (VERSION.SDK_INT < 29) { - caFile = false - when { - ContextCompat.checkSelfPermission(ctx, - Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Read External Storage permission granted") - val downloadsPath = Utils.downloadsPath("ca_certs.crt") - val content = Utils.getFileContents(downloadsPath) - if (content == null) { - alertTitle.value = getString(R.string.error) - alertMessage.value = getString(R.string.read_ca_certs_error) - showAlert.value = true - return@Switch - } - File(BaresipService.filesPath + "/ca_certs.crt").writeBytes(content) - caFile = true - restart = true - } - shouldShowRequestPermissionRationale(Manifest.permission.READ_EXTERNAL_STORAGE) -> { - dialogTitle.value = getString(R.string.notice) - dialogMessage.value = getString(R.string.no_read_permission) - positiveText.value = getString(R.string.ok) - onPositiveClicked.value = { - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - negativeText.value = "" - showDialog.value = true - } - else -> - requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - else - Utils.selectInputFile(caCertsRequest) - } - else { - Utils.deleteFile(File(BaresipService.filesPath + "/ca_certs.crt")) - restart = true - } - } - ) - } - } - - @Composable - private fun UserAgent() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - var userAgent by remember { mutableStateOf(oldUserAgent) } - newUserAgent = userAgent - OutlinedTextField( - value = userAgent, - placeholder = { Text(stringResource(R.string.user_agent)) }, - onValueChange = { - userAgent = it - newUserAgent = userAgent - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - alertTitle.value = getString(R.string.user_agent) - alertMessage.value = getString(R.string.user_agent_help) - showAlert.value = true - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 18.sp, color = LocalCustomColors.current.itemText), - label = { LabelText(stringResource(R.string.user_agent)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - } - } - - @Composable - private fun AudioSettings(ctx: Context) { - Row( - Modifier.fillMaxWidth().padding(top = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.audio_settings), - modifier = Modifier - .weight(1f) - .clickable { - audioRequest.launch(Intent(ctx, AudioActivity::class.java)) - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp, - fontWeight = FontWeight. Bold - ) - } - } - - @Composable - private fun Ringtone() { - oldRingtoneUri = if (Preferences(applicationContext).ringtoneUri == "") - RingtoneManager.getActualDefaultRingtoneUri(applicationContext, RingtoneManager.TYPE_RINGTONE).toString() - else - Preferences(applicationContext).ringtoneUri!! - newRingtoneUri = oldRingtoneUri - val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result: ActivityResult -> - if (result.resultCode == 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) - newRingtoneUri = uri.toString() - } - } - Row( - Modifier - .fillMaxWidth() - .padding(top = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.ringtone), - 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, getString(R.string.select_ringtone)) - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, newRingtoneUri.toUri()) - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) - launcher.launch(intent) - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - } - } - - @Composable - private fun BatteryOptimizations() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.battery_optimizations), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.battery_optimizations) - alertMessage.value = getString(R.string.battery_optimizations_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var battery by remember { mutableStateOf(oldBatteryOptimizations) } - Switch( - checked = battery, - onCheckedChange = { - battery = it - newBatteryOptimizations = battery - try { - androidSettingsRequest.launch(Intent("android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS")) - } catch (e: ActivityNotFoundException) { - Log.e(TAG, "ActivityNotFound exception: ${e.message}") - } - } - ) - } - } - - @RequiresApi(29) - @Composable - private fun DefaultDialer() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.default_phone_app), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.default_phone_app) - alertMessage.value = getString(R.string.default_phone_app_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var defaultDialer by remember { mutableStateOf(oldDefaultDialer) } - Switch( - checked = defaultDialer, - onCheckedChange = { - defaultDialer = it - newDefaultDialer = defaultDialer - if (it) { - if (!roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)) { - alertTitle.value = getString(R.string.alert) - alertMessage.value = getString(R.string.dialer_role_not_available) - showAlert.value = true - } - else - if (!roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) - dialerRoleRequest.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)) - } else { - try { - dialerRoleRequest.launch(Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS")) - } catch (e: ActivityNotFoundException) { - Log.e(TAG, "ActivityNotFound exception: ${e.message}") - } - } - } - ) - } - } - - @Composable - private fun Contacts(ctx: Context) { - val showAlertDialog = remember { mutableStateOf(false) } - Row( - Modifier.fillMaxWidth().padding(top = 12.dp).padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.contacts), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.contacts) - alertMessage.value = getString(R.string.contacts_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - val isDropDownExpanded = remember { - mutableStateOf(false) - } - val contactNames = listOf("baresip", "Android", "Both") - val contactValues = listOf("baresip", "android", "both") - val itemPosition = remember { - mutableIntStateOf(contactValues.indexOf(oldContactsMode)) - } - val contactsPermissions = arrayOf(Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_CONTACTS) - Box { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - isDropDownExpanded.value = true - } - ) { - Text(text = contactNames[itemPosition.intValue], - color = LocalCustomColors.current.itemText) - CustomElements.DrawDrawable(R.drawable.arrow_drop_down, - tint = LocalCustomColors.current.itemText) - } - DropdownMenu( - expanded = isDropDownExpanded.value, - onDismissRequest = { - isDropDownExpanded.value = false - }) { - contactNames.forEachIndexed { index, name -> - DropdownMenuItem(text = { - Text(text = name) - }, - onClick = { - isDropDownExpanded.value = false - val mode = contactValues[index] - if (mode != "baresip" && !Utils.checkPermissions(applicationContext, contactsPermissions)) { - dialogTitle.value = getString(R.string.consent_request) - dialogMessage.value = getString(R.string.contacts_consent) - positiveText.value = getString(R.string.accept) - onPositiveClicked.value = { - showDialog.value = false - newContactsMode = mode - if (ContextCompat.checkSelfPermission( - ctx, - Manifest.permission.READ_CONTACTS - ) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission( - ctx, - Manifest.permission.WRITE_CONTACTS - ) == PackageManager.PERMISSION_GRANTED - ) { - Log.d(TAG, "Contacts permissions already granted") - } else { - if (shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS) || - shouldShowRequestPermissionRationale(Manifest.permission.WRITE_CONTACTS)) - showAlertDialog.value = true - else - requestPermissionsLauncher.launch( - arrayOf( - Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_CONTACTS - ) - ) - } - } - negativeText.value = getString(R.string.deny) - onNegativeClicked.value = { - itemPosition.intValue = contactValues.indexOf(oldContactsMode) - negativeText.value = "" - } - showDialog.value = true - } - else { - itemPosition.intValue = index - newContactsMode = contactValues[index] - } - }) - if (index < 2) - HorizontalDivider( - thickness = 1.dp, - color = LocalCustomColors.current.itemText - ) - } - } - } - if (showAlertDialog.value) - AlertDialog( - showDialog = showAlertDialog, - title = stringResource(R.string.notice), - message = stringResource(R.string.no_android_contacts), - positiveButtonText = stringResource(R.string.ok), - onPositiveClicked = { requestPermissionsLauncher.launch( - arrayOf( - Manifest.permission.READ_CONTACTS, - Manifest.permission.WRITE_CONTACTS - ) - )}, - negativeButtonText = "", - onNegativeClicked = {}, - ) - } - } - - @Composable - private fun DarkTheme() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.dark_theme), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.dark_theme) - alertMessage.value = getString(R.string.dark_theme_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var darkTheme by remember { mutableStateOf(oldDarkTheme) } - Switch( - checked = darkTheme, - onCheckedChange = { - darkTheme = it - newDarkTheme = darkTheme - } - ) - } - } - - @Composable - private fun Debug() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.debug), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.debug) - alertMessage.value = getString(R.string.debug_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var debug by remember { mutableStateOf(oldDebug) } - Switch( - checked = debug, - onCheckedChange = { - debug = it - newDebug = debug - } - ) - } - } - - @Composable - private fun SipTrace() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.sip_trace), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.sip_trace) - alertMessage.value = getString(R.string.sip_trace_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var sipTrace by remember { mutableStateOf(oldSipTrace) } - Switch( - checked = sipTrace, - onCheckedChange = { - sipTrace = it - newSipTrace = sipTrace - } - ) - } - } - - @Composable - private fun Reset() { - Row( - Modifier.fillMaxWidth().padding(end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text(text = stringResource(R.string.reset_config), - modifier = Modifier - .weight(1f) - .clickable { - alertTitle.value = getString(R.string.reset_config) - alertMessage.value = getString(R.string.reset_config_help) - showAlert.value = true - }, - color = LocalCustomColors.current.itemText, - fontSize = 18.sp) - var reset by remember { mutableStateOf(false) } - Switch( - checked = reset, - onCheckedChange = { - dialogTitle.value = getString(R.string.confirmation) - dialogMessage.value = getString(R.string.reset_config_alert) - positiveText.value = getString(R.string.reset) - onPositiveClicked.value = { - Config.reset() - save = false - restart = true - done() - } - negativeText.value = getString(R.string.cancel) - onNegativeClicked.value = { - reset = false - negativeText.value = "" - } - showDialog.value = true - } - ) - } - } - - private fun checkOnClick() { - - if (BaresipService.activities.indexOf("config") == -1) - return - - if (oldAutoStart != newAutoStart) { - Config.replaceVariable("auto_start", - if (newAutoStart) "yes" else "no") - save = true - } - - val listenAddr = newListenAddr.trim() - if (listenAddr != oldListenAddr) { - if ((listenAddr != "") && !Utils.checkIpPort(listenAddr)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = "${getString(R.string.invalid_listen_address)}: $listenAddr" - showAlert.value = true - return - } - Config.replaceVariable("sip_listen", listenAddr) - save = true - restart = true - } - - if (oldAddressFamily != newAddressFamily) { - Config.replaceVariable("net_af", newAddressFamily) - save = true - restart = true - } - - var dnsServers = newDnsServers.lowercase(Locale.ROOT) - .replace(" ", "") - dnsServers = addMissingPorts(dnsServers) - if (dnsServers != oldDnsServers.replace(" ", "")) { - if (!checkDnsServers(dnsServers)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = "${getString(R.string.invalid_dns_servers)}: $dnsServers" - showAlert.value = true - return - } - Config.removeVariable("dns_server") - if (dnsServers.isNotEmpty()) { - for (server in dnsServers.split(",")) - Config.addVariable("dns_server", server) - Config.replaceVariable("dyn_dns", "no") - if (Api.net_use_nameserver(dnsServers) != 0) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = "${getString(R.string.failed_to_set_dns_servers)}: $dnsServers" - showAlert.value = true - return - } - } else { - Config.replaceVariable("dyn_dns", "yes") - Config.updateDnsServers(BaresipService.dnsServers) - } - // Api.net_dns_debug() - save = true - } - - if (oldVerifyServer != newVerifyServer) { - Config.replaceVariable("sip_verify_server", if (newVerifyServer) "yes" else "no") - Api.config_verify_server_set(newVerifyServer) - save = true - } - - newUserAgent = newUserAgent.trim() - if (newUserAgent != oldUserAgent) { - if ((newUserAgent != "") && !Utils.checkServerVal(newUserAgent)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = "${getString(R.string.invalid_user_agent)}: $newUserAgent" - showAlert.value = true - return - } - if (newUserAgent != "") - Config.replaceVariable("user_agent", newUserAgent) - else - Config.removeVariable("user_agent") - save = true - restart = true - } - - Log.d(TAG, "Old/new ringtone: $oldRingtoneUri / $newRingtoneUri") - if (newRingtoneUri != oldRingtoneUri) { - Preferences(applicationContext).ringtoneUri = newRingtoneUri - BaresipService.rt = RingtoneManager.getRingtone(applicationContext, newRingtoneUri.toUri()) - } - if (oldContactsMode != newContactsMode) { - Config.replaceVariable("contacts_mode", newContactsMode) - BaresipService.contactsMode = newContactsMode - when (newContactsMode) { - "baresip" -> { - BaresipService.androidContacts.value = listOf() - Contact.restoreBaresipContacts() - baresipService.action = "Stop Content Observer" - } - "android" -> { - BaresipService.baresipContacts.value = mutableListOf() - Contact.loadAndroidContacts(this) - baresipService.action = "Start Content Observer" - } - "both" -> { - Contact.restoreBaresipContacts() - Contact.loadAndroidContacts(this) - baresipService.action = "Start Content Observer" - } - } - Contact.contactsUpdate() - startService(baresipService) - save = true - } - - val newDisplayTheme = if (newDarkTheme) - AppCompatDelegate.MODE_NIGHT_YES - else - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - if (oldDarkTheme != newDarkTheme) { - Preferences(applicationContext).displayTheme = newDisplayTheme - BaresipService.darkTheme.value = newDarkTheme - Config.replaceVariable("dark_theme", - if (newDarkTheme) "yes" else "no") - save = true - } - - if (oldDebug != newDebug) { - val logLevelString = if (newDebug) "0" else "2" - Config.replaceVariable("log_level", logLevelString) - Api.log_level_set(logLevelString.toInt()) - Log.logLevelSet(logLevelString.toInt()) - save = true - } - - if (oldSipTrace != newSipTrace) { - BaresipService.sipTrace = newSipTrace - Api.uag_enable_sip_trace(newSipTrace) - } - - done() - - } - - override fun onStart() { - super.onStart() - requestPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) {} - } - - override fun onDestroy() { - if (VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun isAppearOnTopPermissionGranted(ctx: Context): Boolean { - return Settings.canDrawOverlays(ctx) - } - - private fun done() { - if (save) - Config.save() - BaresipService.activities.remove("config") - val intent = Intent(this, MainActivity::class.java) - if (restart || audioRestart) - intent.putExtra("restart", true) - setResult(RESULT_OK, intent) - finish() - } - - private fun goBack() { - BaresipService.activities.remove("config") - if (audioRestart) { - val intent = Intent(this, MainActivity::class.java) - intent.putExtra("restart", true) - setResult(RESULT_OK, intent) - } else { - setResult(RESULT_CANCELED, Intent(this, MainActivity::class.java)) - } - finish() - } - - private fun checkDnsServers(dnsServers: String): Boolean { - if (dnsServers.isEmpty()) return true - for (server in dnsServers.split(",")) - if (!Utils.checkIpPort(server.trim())) return false - return true - } - - private fun addMissingPorts(addressList: String): String { - if (addressList == "") return "" - var result = "" - for (addr in addressList.split(",")) - result = if (Utils.checkIpPort(addr)) { - "$result,$addr" - } else { - if (Utils.checkIpV4(addr)) - "$result,$addr:53" - else - "$result,[$addr]:53" - } - return result.substring(1) - } - -} - diff --git a/app/src/main/kotlin/com/tutpro/baresip/Constants.kt b/app/src/main/kotlin/com/tutpro/baresip/Constants.kt index 614c6c85..5e5e96dc 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Constants.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Constants.kt @@ -23,6 +23,7 @@ const val MESSAGE_REQ_CODE = 8 const val REPLY_REQ_CODE = 9 const val SAVE_REQ_CODE = 10 const val DELETE_REQ_CODE = 11 +const val DIRECT_REPLY_REQ_CODE = 12 const val REGISTRATION_INTERVAL = 900 const val NO_AUTH_PASS = "t%Qa?~?J8,~6" @@ -32,6 +33,10 @@ const val MESSAGE_UP = 2131165306 const val MESSAGE_UP_FAIL = 2131165307 const val MESSAGE_UP_WAIT = 2131165308 +val mediaEncMap = mapOf("zrtp" to "ZRTP", "dtls_srtp" to "DTLS-SRTPF", "srtp-mand" to "SRTP-MAND", + "srtp" to "SRTP", "" to "--") + +val mediaNatMap = mapOf("stun" to "STUN", "turn" to "TURN", "ice" to "ICE", "" to "--") diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt deleted file mode 100644 index 7e87dfa7..00000000 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactsActivity.kt +++ /dev/null @@ -1,458 +0,0 @@ -package com.tutpro.baresip - -import android.app.Activity -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.SystemClock -import android.provider.ContactsContract -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -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.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.Observer -import coil.compose.AsyncImage -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.TextAvatar -import com.tutpro.baresip.CustomElements.verticalScrollbar -import java.io.File -import java.io.IOException - -class ContactsActivity : ComponentActivity() { - - private lateinit var aor: String - private var newAndroidName: String? = null - private var lastClick: Long = 0 - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { goBack() } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } - - private val contactRequest = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - if (it.data != null && it.data!!.hasExtra("name")) - newAndroidName = it.data!!.getStringExtra("name") - } - } - - public override fun onCreate(savedInstanceState: Bundle?) { - - super.onCreate(savedInstanceState) - - enableEdgeToEdge() - - if (Build.VERSION.SDK_INT >= 33) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - goBack() - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - - val title = getString(R.string.contacts) - - aor = intent.getStringExtra("aor")!! - Utils.addActivity("contacts,$aor") - - val androidContactsObserver = Observer { - if (newAndroidName != null) { - val contentValues = ContentValues() - contentValues.put(ContactsContract.Contacts.STARRED, 1) - try { - this.contentResolver.update( - ContactsContract.RawContacts.CONTENT_URI, contentValues, - ContactsContract.Contacts.DISPLAY_NAME + "='" + newAndroidName + "'", null - ) - } catch (e: Exception) { - Log.e(TAG, "Update of Android favorite failed: ${e.message}") - } - newAndroidName = null - } - //Contact.contactsUpdate() - } - BaresipService.contactUpdate.observe(this, androidContactsObserver) - - setContent { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - ContactsScreen(LocalContext.current, title) { - goBack() - } - } - } - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun ContactsScreen(ctx: Context, title: String, navigateBack: () -> Unit) { - Scaffold( - modifier = Modifier - .fillMaxHeight() - .imePadding() - .safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = LocalCustomColors.current.light - ) - } - }, - ) - }, - floatingActionButton = { - SmallFloatingActionButton(onClick = { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - val intent = Intent(ctx, BaresipContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", true) - b.putString("uri", "") - intent.putExtras(b) - contactRequest.launch(intent) - }}, - containerColor = LocalCustomColors.current.accent, - contentColor = LocalCustomColors.current.background - ) { - Icon(imageVector = Icons.Filled.Add, - modifier = Modifier.size(36.dp), - contentDescription = stringResource(R.string.add) - ) - } - }, - content = { contentPadding -> - ContactsContent(ctx, contentPadding) - } - ) - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun ContactsContent(ctx: Context, contentPadding: PaddingValues) { - - val showDialog = remember { mutableStateOf(false) } - val dialogMessage = remember { mutableStateOf("") } - val positiveText = remember { mutableStateOf("") } - val positiveAction = remember { mutableStateOf({}) } - val neutralText = remember { mutableStateOf("") } - val neutralAction = remember { mutableStateOf({}) } - - if (showDialog.value) - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = dialogMessage.value, - positiveButtonText = positiveText.value, - onPositiveClicked = positiveAction.value, - neutralButtonText = neutralText.value, - onNeutralClicked = neutralAction.value, - negativeButtonText = stringResource(R.string.cancel) - ) - - val lazyListState = rememberLazyListState() - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .background(LocalCustomColors.current.background) - .padding(contentPadding) - .padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 64.dp) - .verticalScrollbar( - state = lazyListState, - width = 4.dp, - color = LocalCustomColors.current.gray - ), - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(BaresipService.contacts, key = { it.id() }) { contact -> - - val name = contact.name() - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - when(contact) { - - is Contact.BaresipContact -> { - val avatarImage = contact.avatarImage - if (avatarImage != null) - Image( - bitmap = avatarImage.asImageBitmap(), - contentDescription = "Avatar", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - ) - else - TextAvatar(name, contact.color) - } - - is Contact.AndroidContact -> { - val thumbNailUri = contact.thumbnailUri - if (thumbNailUri != null) - AsyncImage( - model = thumbNailUri, - contentDescription = "Avatar", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - ) - else - TextAvatar(name, contact.color) - } - } - - when(contact) { - - is Contact.BaresipContact -> { - Text(text = name, - fontSize = 20.sp, - fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal, - color = LocalCustomColors.current.itemText, - modifier = Modifier - .weight(1f) - .padding(start = 10.dp) - .combinedClickable( - onClick = { - dialogMessage.value = String.format( - getString(R.string.contact_action_question), - name - ) - positiveText.value = getString(R.string.call) - positiveAction.value = { - val i = Intent(ctx, MainActivity::class.java) - i.flags = - Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "call") - val ua = UserAgent.ofAor(aor) - if (ua == null) - Log.w(TAG, "onClickListener did not find AoR $aor") - else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", contact.uri) - (ctx as Activity).startActivity(i) - } - } - neutralText.value = getString(R.string.send_message) - neutralAction.value = { - val i = Intent( - ctx, - MainActivity::class.java - ) - i.flags = - Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "message") - val ua = UserAgent.ofAor(aor) - if (ua == null) - Log.w(TAG, "onClickListener did not find AoR $aor") - else { - BaresipService.activities.clear() - i.putExtra("uap", ua.uap) - i.putExtra("peer", contact.uri) - (ctx as Activity).startActivity(i) - } - } - showDialog.value = true - }, - onLongClick = { - dialogMessage.value = String.format( - getString(R.string.contact_delete_question), - name - ) - positiveText.value = getString(R.string.delete) - positiveAction.value = { - val id = contact.id - val avatarFile = File( - BaresipService.filesPath, - "$id.png" - ) - if (avatarFile.exists()) { - try { - avatarFile.delete() - } catch (e: IOException) { - Log.e( - TAG, - "Could not delete file $id.png: ${e.message}" - ) - } - } - Contact.removeBaresipContact(contact) - } - showDialog.value = true - } - ) - ) - SmallFloatingActionButton( - modifier = Modifier.padding(end = 10.dp), - onClick = { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - val intent = Intent(ctx, BaresipContactActivity::class.java) - val b = Bundle() - b.putBoolean("new", false) - b.putString("name", name) - intent.putExtras(b) - contactRequest.launch(intent) - } - }, - containerColor = LocalCustomColors.current.background, - contentColor = LocalCustomColors.current.secondary - ) { - Icon( - imageVector = Icons.Filled.Edit, - modifier = Modifier.size(28.dp), - contentDescription = stringResource(R.string.edit) - ) - } - } - - is Contact.AndroidContact -> { - Text(text = name, - fontSize = 20.sp, - fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal, - color = LocalCustomColors.current.itemText, - modifier = Modifier - .weight(1f) - .padding(start = 10.dp, top = 4.dp, bottom = 4.dp) - .combinedClickable( - onClick = { - if (SystemClock.elapsedRealtime() - lastClick > 1000) { - lastClick = SystemClock.elapsedRealtime() - val i = - Intent(ctx, AndroidContactActivity::class.java) - val b = Bundle() - b.putString("aor", aor) - b.putString("name", name) - i.putExtras(b) - ctx.startActivity(i, null) - } - }, - onLongClick = { - dialogMessage.value = String.format( - getString(R.string.contact_delete_question), - name - ) - positiveText.value = getString(R.string.delete) - positiveAction.value = { - ctx.contentResolver.delete( - ContactsContract.RawContacts.CONTENT_URI, - ContactsContract.Contacts.DISPLAY_NAME + "='" + name + "'", - null - ) - } - showDialog.value = true - } - ) - ) - } - } - } - } - } - } - - override fun onDestroy() { - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - super.onDestroy() - } - - private fun goBack() { - BaresipService.activities.remove("contacts,$aor") - setResult(RESULT_OK, Intent()) - finish() - } - -} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt new file mode 100644 index 00000000..15c4ef6f --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt @@ -0,0 +1,332 @@ +package com.tutpro.baresip + +import android.content.Context +import android.content.Intent +import android.provider.ContactsContract +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import coil.compose.AsyncImage +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.TextAvatar +import com.tutpro.baresip.CustomElements.verticalScrollbar +import java.io.File +import java.io.IOException + +const val avatarSize: Int = 96 + +fun NavGraphBuilder.contactsScreenRoute( + navController: NavController, + viewModel: ViewModel +) { + composable("contacts") { backStackEntry -> + ContactsScreen(navController = navController, viewModel = viewModel) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContactsScreen(navController: NavController, viewModel: ViewModel) { + + val ctx = LocalContext.current + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.contacts), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = LocalCustomColors.current.light + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + ) + } + }, + floatingActionButton = { + SmallFloatingActionButton(onClick = { navController.navigate("baresip_contact//new") }, + containerColor = LocalCustomColors.current.accent, + contentColor = LocalCustomColors.current.background + ) { + Icon(imageVector = Icons.Filled.Add, + modifier = Modifier.size(36.dp), + contentDescription = stringResource(R.string.add) + ) + } + }, + content = { contentPadding -> + ContactsContent(ctx, viewModel, navController, contentPadding) + } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ContactsContent( + ctx: Context, + viewModel: ViewModel, + navController: NavController, + contentPadding: PaddingValues +) { + val showDialog = remember { mutableStateOf(false) } + val dialogMessage = remember { mutableStateOf("") } + val positiveText = remember { mutableStateOf("") } + val positiveAction = remember { mutableStateOf({}) } + val neutralText = remember { mutableStateOf("") } + val neutralAction = remember { mutableStateOf({}) } + + if (showDialog.value) + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = dialogMessage.value, + positiveButtonText = positiveText.value, + onPositiveClicked = positiveAction.value, + neutralButtonText = neutralText.value, + onNeutralClicked = neutralAction.value, + negativeButtonText = stringResource(R.string.cancel) + ) + + val lazyListState = rememberLazyListState() + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding(contentPadding) + .padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 64.dp) + .verticalScrollbar( + state = lazyListState, + width = 4.dp, + color = LocalCustomColors.current.gray + ), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(BaresipService.contacts, key = { it.id() }) { contact -> + + val name = contact.name() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + when(contact) { + + is Contact.BaresipContact -> { + val avatarImage = contact.avatarImage + if (avatarImage != null) + Image( + bitmap = avatarImage.asImageBitmap(), + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + else + TextAvatar(name, contact.color) + } + + is Contact.AndroidContact -> { + val thumbNailUri = contact.thumbnailUri + if (thumbNailUri != null) + AsyncImage( + model = thumbNailUri, + contentDescription = "Avatar", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + else + TextAvatar(name, contact.color) + } + } + + when(contact) { + + is Contact.BaresipContact -> { + Text(text = name, + fontSize = 20.sp, + fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal, + color = LocalCustomColors.current.itemText, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp) + .combinedClickable( + onClick = { + val aor = viewModel.selectedAor.value + val ua = UserAgent.ofAor(aor) + val intent = Intent(ctx, MainActivity::class.java) + if (ua != null) { + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", contact.uri) + } + else + Log.w(TAG, "onClickListener did not find UA for $aor") + dialogMessage.value = String.format( + ctx.getString(R.string.contact_action_question), + name + ) + positiveText.value = ctx.getString(R.string.call) + positiveAction.value = { + if (ua != null) { + handleIntent(ctx, viewModel, intent, "call") + navController.popBackStack() + } + } + neutralText.value = ctx.getString(R.string.send_message) + neutralAction.value = { + if (ua != null) { + handleIntent(ctx, viewModel, intent, "message") + navController.popBackStack() + } + } + showDialog.value = true + }, + onLongClick = { + dialogMessage.value = String.format( + ctx.getString(R.string.contact_delete_question), + name + ) + positiveText.value = ctx.getString(R.string.delete) + positiveAction.value = { + val id = contact.id + val avatarFile = File( + BaresipService.filesPath, + "$id.png" + ) + if (avatarFile.exists()) { + try { + avatarFile.delete() + } catch (e: IOException) { + Log.e( + TAG, + "Could not delete file $id.png: ${e.message}" + ) + } + } + Contact.removeBaresipContact(contact) + } + showDialog.value = true + } + ) + ) + SmallFloatingActionButton( + modifier = Modifier.padding(end = 10.dp), + onClick = { navController.navigate("baresip_contact/${name}/old") }, + containerColor = LocalCustomColors.current.background, + contentColor = LocalCustomColors.current.secondary + ) { + Icon( + imageVector = Icons.Filled.Edit, + modifier = Modifier.size(28.dp), + contentDescription = stringResource(R.string.edit) + ) + } + } + + is Contact.AndroidContact -> { + Text(text = name, + fontSize = 20.sp, + fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal, + color = LocalCustomColors.current.itemText, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp, top = 4.dp, bottom = 4.dp) + .combinedClickable( + onClick = { navController.navigate("android_contact/${name}") }, + onLongClick = { + dialogMessage.value = String.format( + ctx.getString(R.string.contact_delete_question), + name + ) + positiveText.value = ctx.getString(R.string.delete) + positiveAction.value = { + ctx.contentResolver.delete( + ContactsContract.RawContacts.CONTENT_URI, + ContactsContract.Contacts.DISPLAY_NAME + "='" + name + "'", + null + ) + } + neutralText.value = "" + showDialog.value = true + } + ) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt b/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt index d92d1ef3..2ad930b4 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt @@ -5,6 +5,7 @@ import android.graphics.Bitmap import android.widget.Toast import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState @@ -117,12 +118,14 @@ object CustomElements { onLongClick: () -> Unit, modifier: Modifier = Modifier, shape: Shape, + border: BorderStroke? = null, color: Color, content: @Composable RowScope.() -> Unit ) { Surface( shape = shape, color = color, + border = border, modifier = modifier .pointerInput(Unit) { detectTapGestures( @@ -491,7 +494,7 @@ object CustomElements { BasicAlertDialog( properties = DialogProperties( dismissOnBackPress = false, - dismissOnClickOutside = false + dismissOnClickOutside = false, ), onDismissRequest = { keyboardController?.hide() diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt index c33df5b9..0046e13f 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt @@ -17,140 +17,24 @@ import android.content.Intent.ACTION_CALL import android.content.Intent.ACTION_DIAL import android.content.Intent.ACTION_VIEW import android.content.IntentFilter -import android.content.pm.PackageManager -import android.content.res.Configuration -import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.media.AudioManager -import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Process -import android.os.SystemClock -import android.provider.DocumentsContract -import android.provider.MediaStore import android.view.KeyEvent import android.view.WindowManager import android.view.inputmethod.InputMethodManager -import android.widget.Chronometer -import android.widget.Toast -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator -import androidx.compose.material3.pulltorefresh.pullToRefresh -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.lifecycle.Observer -import com.tutpro.baresip.BaresipService.Companion.contactNames -import com.tutpro.baresip.BaresipService.Companion.uas -import com.tutpro.baresip.BaresipService.Companion.uasStatus -import com.tutpro.baresip.CustomElements.AlertDialog -import com.tutpro.baresip.CustomElements.DropdownMenu -import com.tutpro.baresip.CustomElements.LabelText -import com.tutpro.baresip.CustomElements.PasswordDialog -import com.tutpro.baresip.CustomElements.SelectableAlertDialog -import com.tutpro.baresip.CustomElements.verticalScrollbar -import kotlinx.coroutines.delay -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -163,100 +47,16 @@ class MainActivity : ComponentActivity() { private lateinit var serviceEventObserver: Observer> private lateinit var requestPermissionLauncher: ActivityResultLauncher private lateinit var requestPermissionsLauncher: ActivityResultLauncher> - private lateinit var accountsRequest: ActivityResultLauncher - private lateinit var chatRequests: ActivityResultLauncher - private lateinit var configRequest: ActivityResultLauncher - private lateinit var backupRequest: ActivityResultLauncher - private lateinit var restoreRequest: ActivityResultLauncher - private lateinit var logcatRequest: ActivityResultLauncher - private lateinit var contactsRequest: ActivityResultLauncher - private lateinit var callsRequest: ActivityResultLauncher - private lateinit var accountRequest: ActivityResultLauncher private lateinit var comDevChangedListener: AudioManager.OnCommunicationDeviceChangedListener - private lateinit var permissions: Array private lateinit var baresipService: Intent - private var callHandler: Handler = Handler(Looper.getMainLooper()) - private var callRunnable: Runnable? = null - private var downloadsInputUri: Uri? = null - private var downloadsOutputUri: Uri? = null - private var audioModeChangedListener: AudioManager.OnModeChangedListener? = null - private var keyboardController: SoftwareKeyboardController? = null - private var restart = false private var atStartup = false private var initialized = false - private var resumeUri = "" - private var resumeUap = 0L - private var resumeCall: Call? = null - private var resumeAction = "" - private val viewModel: ViewModel by viewModels() - - private var callUri = mutableStateOf("") - private var callUriEnabled = mutableStateOf(true) - private var callUriLabel = mutableStateOf("") - private var securityIcon = mutableIntStateOf(-1) - private var callTimer: Chronometer? = null - private var showCallTimer = mutableStateOf(false) - private var showSuggestions = mutableStateOf(false) - private var showCallButton = mutableStateOf(true) - private var showCancelButton = mutableStateOf(false) - private var showAnswerRejectButtons = mutableStateOf(false) - private var showHangupButton = mutableStateOf(false) - private var showOnHoldNotice = mutableStateOf(false) - private var showPasswordDialog = mutableStateOf(false) - private var password = mutableStateOf("") - private var showPasswordsDialog = mutableStateOf(false) - private var holdIcon = mutableIntStateOf(R.drawable.call_hold) - private var transferButtonEnabled = mutableStateOf(false) - private var transferIcon = mutableIntStateOf(R.drawable.call_transfer) - private var dtmfText = mutableStateOf("") - private var dtmfEnabled = mutableStateOf(false) - private var focusDtmf = mutableStateOf(false) - private var showVmIcon by mutableStateOf(false) - private var vmIcon = mutableIntStateOf(R.drawable.voicemail) - private var messagesIcon = mutableIntStateOf(R.drawable.messages) - private var callsIcon = mutableIntStateOf(R.drawable.calls) - private var micIcon by mutableIntStateOf(R.drawable.mic_on) - private var speakerIcon by mutableIntStateOf(R.drawable.speaker_off) - private var dialpad by mutableStateOf(false) - private var dialpadButtonEnabled by mutableStateOf(true) - private var pullToRefreshEnabled by mutableStateOf(true) - private var passwordAccounts = mutableListOf() - private var passwordTitle by mutableStateOf("") - - private val alertTitle = mutableStateOf("") - private val alertMessage = mutableStateOf("") - private val showAlert = mutableStateOf(false) - - private val dialogTitle = mutableStateOf("") - private val dialogMessage = mutableStateOf("") - private val positiveText = mutableStateOf("") - private val onPositiveClicked = mutableStateOf({}) - private val negativeText = mutableStateOf("") - private val onNegativeClicked = mutableStateOf({}) - private val showDialog = mutableStateOf(false) - - private val showSelectItemDialog = mutableStateOf(false) - val items = mutableStateOf(listOf()) - private val itemAction = mutableStateOf<(Int) -> Unit>({ _ -> run {} }) - - private var backInvokedCallback: OnBackInvokedCallback? = null - private lateinit var onBackPressedCallback: OnBackPressedCallback - - @RequiresApi(33) - private fun registerBackInvokedCallback() { - backInvokedCallback = OnBackInvokedCallback { - moveTaskToBack(true) - } - onBackInvokedDispatcher.registerOnBackInvokedCallback( - OnBackInvokedDispatcher.PRIORITY_DEFAULT, - backInvokedCallback!! - ) - } + private lateinit var navController: NavHostController @SuppressLint("ClickableViewAccessibility") override fun onCreate(savedInstanceState: Bundle?) { @@ -265,19 +65,8 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - registerBackInvokedCallback() - else { - onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - moveTaskToBack(true) - } - } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - } - val extraAction = intent.getStringExtra("action") - Log.d(TAG, "Main onCreate ${intent.action}/${intent.data}/$extraAction") + Log.e(TAG, "Main onCreate ${intent.action}/${intent.data}/$extraAction") window.addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES) @@ -298,7 +87,35 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "Observed event $event") if (event != null && BaresipService.serviceEvents.isNotEmpty()) { val first = BaresipService.serviceEvents.removeAt(0) - handleServiceEvent(first.event, first.params) + if (taskId != -1) { + if (first.event == "started" && !initialized) + // Android has restarted baresip when permission has been denied in app settings + recreate() + else { + if (first.event == "stopped") { + Log.d( + TAG, + "Handling service event 'stopped' with start error '${first.params[0]}'" + ) + if (first.params[0] != "") + handleDialog( + ctx = applicationContext, + title = getString(R.string.notice), + message =getString(R.string.start_failed) + ) + else { + finishAndRemoveTask() + if (restart) + reStart() + else + exitProcess(0) + } + } else + handleServiceEvent(applicationContext, viewModel, first.event, first.params) + } + } + else + Log.d(TAG, "Omit service event '$event' for task -1") } } @@ -321,10 +138,11 @@ class MainActivity : ComponentActivity() { comDevChangedListener = AudioManager.OnCommunicationDeviceChangedListener { device -> if (device != null) { Log.d(TAG, "Com device changed to type ${device.type} in mode ${am.mode}") - speakerIcon = if (Utils.isSpeakerPhoneOn(am)) + val speakerIcon = if (Utils.isSpeakerPhoneOn(am)) R.drawable.speaker_on else R.drawable.speaker_off + viewModel.updateSpeakerIcon(speakerIcon) } } am.addOnCommunicationDeviceChangedListener(mainExecutor, comDevChangedListener) @@ -332,113 +150,47 @@ class MainActivity : ComponentActivity() { initialized = true - accountsRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (Account.ofAor(activityAor) != null) - spinToAor(activityAor) - else { - if (Account.ofAor(viewModel.selectedAor.value) == null) { - if (uas.value.isNotEmpty()) - viewModel.updateSelectedAor(uas.value.first().account.aor) - else - viewModel.updateSelectedAor("") - } - updateIcons(Account.ofAor(viewModel.selectedAor.value)) - } - if (BaresipService.isServiceRunning) { - baresipService.action = "Update Notification" - startService(baresipService) - } - } - - accountRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - spinToAor(activityAor) - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - updateIcons(ua.account) - } - - contactsRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - } - - chatRequests = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - spinToAor(activityAor) - updateIcons(Account.ofAor(activityAor)!!) - } - - callsRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - spinToAor(activityAor) - updateIcons(Account.ofAor(viewModel.selectedAor.value)) - } - - configRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if ((it.data != null) && it.data!!.hasExtra("restart")) { - dialogTitle.value = getString(R.string.restart_request) - dialogMessage.value = getString(R.string.config_restart) - positiveText.value = getString(R.string.restart) - onPositiveClicked.value = { - quitRestart(true) - } - negativeText.value = getString(R.string.cancel) - showDialog.value = true - } - val displayTheme = Preferences(applicationContext).displayTheme - if (displayTheme != AppCompatDelegate.getDefaultNightMode()) { - AppCompatDelegate.setDefaultNightMode(displayTheme) - } - } - - backupRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) - it.data?.data?.also { uri -> - downloadsOutputUri = uri - passwordTitle = getString(R.string.encrypt_password) - showPasswordDialog.value = true - } - } - - restoreRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) - it.data?.data?.also { uri -> - downloadsInputUri = uri - passwordTitle = getString(R.string.decrypt_password) - showPasswordDialog.value = true - } - } - - logcatRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) - it.data?.data?.also { uri -> - try { - val out = contentResolver.openOutputStream(uri) - val process = Runtime.getRuntime().exec("logcat -d --pid=${Process.myPid()}") - val bufferedReader = process.inputStream.bufferedReader() - bufferedReader.forEachLine { line -> - out!!.write(line.toByteArray()) - out.write('\n'.code.toByte().toInt()) - } - out!!.close() - } catch (e: Exception) { - Log.e(TAG, "Failed to write logcat to file: $e") - } - } - } - - micIcon = if (BaresipService.isMicMuted) + viewModel.updateMicIcon(if (BaresipService.isMicMuted) R.drawable.mic_off else R.drawable.mic_on + ) - setContent { - AppTheme { - keyboardController = LocalSoftwareKeyboardController.current - Surface( - modifier = Modifier.fillMaxSize(), - color = LocalCustomColors.current.background - ) { - MainScreen(this) + val restartApp = { + Log.i(TAG, "Restarting baresip") + window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + if (BaresipService.isServiceRunning) { + restart = true + baresipService.action = "Stop" + startService(baresipService) + } else { + finishAndRemoveTask() + val pm = applicationContext.packageManager + val intent = pm.getLaunchIntentForPackage(applicationContext.packageName) + if (intent != null) { + applicationContext.startActivity(intent) + exitProcess(0) + } else { + Log.e(TAG, "Failed to restart: Launch intent is null") } } } + val quitApp = { + Log.i(TAG, "Quiting baresip") + window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + if (BaresipService.isServiceRunning) { + restart = false + baresipService.action = "Stop" + startService(baresipService) + } else { + finishAndRemoveTask() + exitProcess(0) + } + } + baresipService = Intent(this@MainActivity, BaresipService::class.java) atStartup = intent.hasExtra("onStartup") @@ -446,21 +198,26 @@ class MainActivity : ComponentActivity() { when (intent?.action) { ACTION_DIAL, ACTION_CALL, ACTION_VIEW -> if (BaresipService.isServiceRunning) - callAction(intent.data, if (intent?.action == ACTION_CALL) "call" else "dial") + callAction(applicationContext, viewModel, intent.data, if (intent?.action == ACTION_CALL) "call" else "dial") else - BaresipService.callActionUri = intent.data.toString() - .replace("tel:%2B", "tel:+") + BaresipService.callActionUri = intent.data.toString().replace("%2B", "+") + .replace("%20", "").filterNot{setOf('-', ' ', '(', ')').contains(it)} } - permissions = if (Build.VERSION.SDK_INT >= 33) + val permissions = if (Build.VERSION.SDK_INT >= 33) arrayOf(POST_NOTIFICATIONS, RECORD_AUDIO, BLUETOOTH_CONNECT) else if (Build.VERSION.SDK_INT >= 31) arrayOf(RECORD_AUDIO, BLUETOOTH_CONNECT) else - arrayOf(RECORD_AUDIO) + if (Build.VERSION.SDK_INT < 29) + arrayOf(RECORD_AUDIO, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE) + else + arrayOf(RECORD_AUDIO) requestPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + Log.i(TAG, "Permission granted: $isGranted") + } requestPermissionsLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { @@ -474,1701 +231,121 @@ class MainActivity : ComponentActivity() { } } if (denied.contains(POST_NOTIFICATIONS) && !shouldShow.contains(POST_NOTIFICATIONS)) { - dialogTitle.value = getString(R.string.notice) - dialogMessage.value = getString(R.string.no_notifications) - positiveText.value = getString(R.string.ok) - onPositiveClicked.value = { - quitRestart(false) - } - negativeText.value = "" - showDialog.value = true + handleDialog( + ctx = applicationContext, + title = getString(R.string.notice), + message = getString(R.string.no_notifications), + action = { quitRestart(false) } + ) } else { if (shouldShow.isNotEmpty()) { - dialogTitle.value = getString(R.string.permissions_rationale) - dialogMessage.value = getString(R.string.audio_permissions) - positiveText.value = getString(R.string.ok) - onPositiveClicked.value = { - requestPermissionsLauncher.launch(permissions) - } - negativeText.value = "" - showDialog.value = true + handleDialog( + ctx = applicationContext, + title = getString(R.string.permissions_rationale), + message = getString(R.string.audio_permissions), + action = { requestPermissionsLauncher.launch(permissions) } + ) + } + else { + if (!BaresipService.isStartReceived) { + baresipService.action = "Start" + startService(baresipService) + if (atStartup) + moveTaskToBack(true) + } } - else - startBaresip() } } - if (!BaresipService.isServiceRunning) { - if (File(filesDir.absolutePath + "/accounts").exists()) { - passwordAccounts = String( - Utils.getFileContents(filesDir.absolutePath + "/accounts")!!, - Charsets.UTF_8 - ).lines().toMutableList() - showPasswordsDialog.value = true - } else { - // Baresip is started for the first time - requestPermissionsLauncher.launch(permissions) + setContent { + + AppTheme { + + navController = rememberNavController() + + LaunchedEffect(key1 = viewModel, key2 = navController) { + viewModel.navigationCommand.collect { command -> + Log.d(TAG, "MainActivity: Received NavigationCommand: $command") + when (command) { + is NavigationCommand.NavigateToChat -> { + val route = "chat/${command.aor}/${command.peer}" + navController.navigate(route) { + launchSingleTop = true + popUpTo("main") + } + } + is NavigationCommand.NavigateToCalls -> { + val route = "calls/${command.aor}" + navController.navigate(route) { + launchSingleTop = true + popUpTo("main") + } + } + is NavigationCommand.NavigateToHome -> { + navController.navigate("main") { + launchSingleTop = true + popUpTo("main") + } + } + } + } + } + + NavHost(navController, startDestination = "main") { + mainScreenRoute( + navController = navController, + viewModel = viewModel, + onRequestPermissions = { requestPermissionsLauncher.launch(permissions) }, + onRestartApp = { restartApp() }, + onQuitApp = { quitApp() } + ) + aboutScreenRoute(navController) + settingsScreenRoute( + navController = navController, + onRestartApp = { restartApp() } + ) + accountsScreenRoute(navController) + audioScreenRoute(navController) + accountScreenRoute(navController) + codecsScreenRoute(navController) + contactsScreenRoute(navController, viewModel) + baresipContactScreenRoute(navController) + androidContactScreenRoute(navController, viewModel) + callsScreenRoute(navController, viewModel) + callDetailsScreenRoute(navController, viewModel) + chatsScreenRoute(navController) + chatScreenRoute(navController, viewModel) + } } } } // OnCreate - @Composable - private fun MainScreen(ctx: Context) { - Scaffold( - modifier = Modifier.safeDrawingPadding(), - containerColor = LocalCustomColors.current.background, - topBar = { TopAppBar(ctx, getString(R.string.baresip)) }, - bottomBar = { BottomBar(ctx) }, - content = { contentPadding -> - MainContent(ctx, contentPadding) - } - ) - } - - @Composable - fun MainContent(ctx: Context, contentPadding: PaddingValues) { - - var isRefreshing by remember { mutableStateOf(false) } - val refreshState = rememberPullToRefreshState() - var offset by remember { mutableFloatStateOf(0f) } - val swipeThreshold = 200 - - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - delay(1000) - isRefreshing = false - } - } - - if (showAlert.value) - AlertDialog( - showDialog = showAlert, - title = alertTitle.value, - message = alertMessage.value, - positiveButtonText = stringResource(R.string.ok), - ) - - if (showDialog.value) - AlertDialog( - showDialog = showDialog, - title = stringResource(R.string.confirmation), - message = dialogMessage.value, - positiveButtonText = positiveText.value, - onPositiveClicked = onPositiveClicked.value, - negativeButtonText = stringResource(R.string.cancel), - onNegativeClicked = onNegativeClicked.value - ) - - SelectableAlertDialog( - openDialog = showSelectItemDialog, - title = stringResource(R.string.choose_destination_uri), - items = items.value, - onItemClicked = itemAction.value, - neutralButtonText = stringResource(R.string.cancel), - onNeutralClicked = {} - ) - - Column( - modifier = Modifier - .imePadding() - .fillMaxWidth() - .padding(contentPadding) - .padding(top = 18.dp, bottom = 6.dp, start = 16.dp, end = 16.dp) - .fillMaxSize() - .pullToRefresh( - state = refreshState, - isRefreshing = isRefreshing, - onRefresh = { - isRefreshing = true - if (uas.value.isNotEmpty()) { - if (viewModel.selectedAor.value == "") - spinToAor(uas.value.first().account.aor) - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - if (ua.account.regint > 0) - Api.ua_register(ua.uap) - } - }, - enabled = pullToRefreshEnabled, - ) - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { offset = 0f }, - onDragEnd = { - if (offset < -swipeThreshold) { - if (uas.value.isNotEmpty()) { - val curPos = UserAgent.findAorIndex(viewModel.selectedAor.value) - val newPos = if (curPos == null) - 0 - else - (curPos + 1) % uas.value.size - if (curPos != newPos) { - val ua = uas.value[newPos] - spinToAor(ua.account.aor) - showCall(ua) - } - } - } else if (offset > swipeThreshold) { - if (uas.value.isNotEmpty()) { - val curPos = UserAgent.findAorIndex(viewModel.selectedAor.value) - val newPos = when (curPos) { - null -> 0 - 0 -> uas.value.size - 1 - else -> curPos - 1 - } - if (curPos != newPos) { - val ua = uas.value[newPos] - spinToAor(ua.account.aor) - showCall(ua) - } - } - } - } - ) { _, dragAmount -> - offset += dragAmount - } - } - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AccountSpinner(ctx) - CallUriRow() - CallRow(ctx) - OnHoldNotice() - AskPasswords(ctx) - AskPassword(ctx) - Indicator( - modifier = Modifier.align(Alignment.CenterHorizontally), - isRefreshing = isRefreshing, - state = refreshState, - ) - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun TopAppBar(ctx: Context, title: String) { - - val recOffImage = ImageVector.vectorResource(R.drawable.rec_off) - val recOnImage = ImageVector.vectorResource(R.drawable.rec_on) - - var recImage by remember { mutableStateOf(recOffImage) } - var menuExpanded by remember { mutableStateOf(false) } - - val about = stringResource(R.string.about) - val settings = stringResource(R.string.configuration) - val accounts = stringResource(R.string.accounts) - val backup = stringResource(R.string.backup) - val restore = stringResource(R.string.restore) - val logcat = stringResource(R.string.logcat) - val restart = stringResource(R.string.restart) - val quit = stringResource(R.string.quit) - - TopAppBar( - title = { - Text( - text = title, - color = LocalCustomColors.current.light, - fontSize = 22.sp, - fontWeight = FontWeight.Bold - ) - }, - colors = TopAppBarDefaults.mediumTopAppBarColors( - containerColor = LocalCustomColors.current.primary - ), - actions = { - - Icon( - imageVector = recImage, - modifier = Modifier.size(40.dp).combinedClickable( - onClick = { - if (Call.call("connected") == null) { - BaresipService.isRecOn = !BaresipService.isRecOn - recImage = if (BaresipService.isRecOn) { - Api.module_load("sndfile") - recOnImage - } else { - Api.module_unload("sndfile") - recOffImage - } - } else - Toast.makeText(ctx, R.string.rec_in_call, Toast.LENGTH_SHORT) - .show() - }, - onLongClick = { - alertTitle.value = getString(R.string.call_recording_title) - alertMessage.value = getString(R.string.call_recording_tip) - showAlert.value = true - } - ), - tint = Color.Unspecified, - contentDescription = null - ) - - Spacer(modifier = Modifier.width(22.dp)) - - Icon( - imageVector = ImageVector.vectorResource(micIcon), - modifier = Modifier - .size(40.dp) - .combinedClickable( - onClick = { - if (Call.call("connected") != null) { - BaresipService.isMicMuted = !BaresipService.isMicMuted - if (BaresipService.isMicMuted) { - micIcon = R.drawable.mic_off - Api.calls_mute(true) - } else { - micIcon = R.drawable.mic_on - Api.calls_mute(false) - } - } - }, - onLongClick = { - alertTitle.value = getString(R.string.microphone_title) - alertMessage.value = getString(R.string.microphone_tip) - showAlert.value = true - }, - ), - tint = Color.Unspecified, - contentDescription = null - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Icon( - imageVector = ImageVector.vectorResource(speakerIcon), - modifier = Modifier - .size(40.dp) - .combinedClickable( - onClick = { - if (Build.VERSION.SDK_INT >= 31) - Log.d( - TAG, "Toggling speakerphone when dev/mode is " + - "${am.communicationDevice!!.type}/${am.mode}" - ) - Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(ctx), am) - speakerIcon = if (Utils.isSpeakerPhoneOn(am)) - R.drawable.speaker_on - else - R.drawable.speaker_off - }, - onLongClick = { - alertTitle.value = getString(R.string.speakerphone_title) - alertMessage.value = getString(R.string.speakerphone_tip) - showAlert.value = true - }, - ), - tint = Color.Unspecified, - contentDescription = null - ) - - Spacer(modifier = Modifier.width(8.dp)) - - IconButton( - onClick = { menuExpanded = !menuExpanded } - ) { - Icon( - imageVector = Icons.Filled.Menu, - contentDescription = "Menu", - tint = LocalCustomColors.current.light - ) - } - - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - items = if (Build.VERSION.SDK_INT >= 29) - listOf(about, settings, accounts, backup, restore, logcat, restart, quit) - else - listOf(about, settings, accounts, backup, restore, restart, quit), - onItemClick = { selectedItem -> - menuExpanded = false - when (selectedItem) { - about -> { - startActivity(Intent(ctx, AboutActivity::class.java)) - } - settings -> { - configRequest.launch(Intent(ctx, ConfigActivity::class.java)) - } - accounts -> { - val i = Intent(ctx, AccountsActivity::class.java) - val b = Bundle() - b.putString("aor", viewModel.selectedAor.value) - i.putExtras(b) - accountsRequest.launch(i) - } - backup -> { - when { - Build.VERSION.SDK_INT >= 29 -> - pickupFileFromDownloads("backup") - ContextCompat.checkSelfPermission(ctx, WRITE_EXTERNAL_STORAGE) == - PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Write External Storage permission granted") - val path = Utils.downloadsPath("baresip.bs") - downloadsOutputUri = File(path).toUri() - passwordTitle = getString(R.string.encrypt_password) - showPasswordDialog.value = true - } - shouldShowRequestPermissionRationale(WRITE_EXTERNAL_STORAGE) -> { - dialogTitle.value = getString(R.string.notice) - dialogMessage.value = getString(R.string.no_backup) - positiveText.value = getString(R.string.ok) - onPositiveClicked.value = { - requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE) - } - negativeText.value = "" - showDialog.value = true - } - else -> - requestPermissionLauncher.launch(WRITE_EXTERNAL_STORAGE) - } - } - restore -> { - when { - Build.VERSION.SDK_INT >= 29 -> - pickupFileFromDownloads("restore") - ContextCompat.checkSelfPermission(ctx, READ_EXTERNAL_STORAGE) == - PackageManager.PERMISSION_GRANTED -> { - Log.d(TAG, "Read External Storage permission granted") - val path = Utils.downloadsPath("baresip.bs") - downloadsInputUri = File(path).toUri() - passwordTitle = getString(R.string.decrypt_password) - showPasswordDialog.value = true - } - shouldShowRequestPermissionRationale(READ_EXTERNAL_STORAGE) -> { - dialogTitle.value = getString(R.string.notice) - dialogMessage.value = getString(R.string.no_restore) - positiveText.value = getString(R.string.ok) - onPositiveClicked.value = { - requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE) - } - negativeText.value = "" - showDialog.value = true - } - else -> - requestPermissionLauncher.launch(READ_EXTERNAL_STORAGE) - } - } - logcat -> { - if (Build.VERSION.SDK_INT >= 29) - pickupFileFromDownloads("logcat") - } - restart -> { - quitRestart(true) - } - quit -> { - quitRestart(false) - } - } - } - ) - } - ) - } - - @OptIn(ExperimentalFoundationApi::class) - @Composable - fun AccountSpinner(ctx: Context) { - - var expanded by rememberSaveable { mutableStateOf(false) } - val selected: String by viewModel.selectedAor.collectAsState() - - if (uas.value.isEmpty()) - viewModel.updateSelectedAor("") - else - if (selected == "" || UserAgent.ofAor(selected) == null) { - viewModel.updateSelectedAor(uas.value.first().account.aor) - } - - showCall(UserAgent.ofAor(selected)) - updateIcons(Account.ofAor(selected)) - - if (selected == "") { - OutlinedButton( - onClick = { - val i = Intent(this@MainActivity, AccountsActivity::class.java) - val b = Bundle() - b.putString("aor", selected) - i.putExtras(b) - accountsRequest.launch(i) - }, - modifier = Modifier - .padding(horizontal = 4.dp) - .height(50.dp) - .fillMaxWidth(), - colors = ButtonColors( - containerColor = LocalCustomColors.current.grayLight, - contentColor = LocalCustomColors.current.dark, - disabledContainerColor = LocalCustomColors.current.grayLight, - disabledContentColor = LocalCustomColors.current.dark - ), - shape = RoundedCornerShape(12.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Text(text = "") - } - } - else - OutlinedButton( - onClick = { - expanded = !expanded - }, - enabled = true, - modifier = Modifier - .padding(horizontal = 4.dp) - .height(50.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - expanded = true - }, - onLongPress = { - val ua = UserAgent.ofAor(selected) - if (ua != null) { - val acc = ua.account - if (Api.account_regint(acc.accp) > 0) { - Api.account_set_regint(acc.accp, 0) - Api.ua_unregister(ua.uap) - } else { - Api.account_set_regint( - acc.accp, - acc.configuredRegInt - ) - Api.ua_register(ua.uap) - } - acc.regint = Api.account_regint(acc.accp) - AccountsActivity.saveAccounts() - } - } - ) - }, - colors = ButtonColors( - containerColor = LocalCustomColors.current.grayLight, - contentColor = LocalCustomColors.current.dark, - disabledContainerColor = LocalCustomColors.current.grayLight, - disabledContentColor = LocalCustomColors.current.dark - ), - shape = RoundedCornerShape(12.dp), - contentPadding = PaddingValues(horizontal = 10.dp) - ) { - Icon( - imageVector = ImageVector.vectorResource( - uasStatus.value[selected] ?: - R.drawable.locked_yellow), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier - .padding(end = 10.dp) - .clickable(onClick = { - val i = Intent(ctx, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", selected) - i.putExtras(b) - accountRequest.launch(i) - }) - ) - Text( - text = Account.ofAor(selected)?.text() ?: "", - fontSize = 17.sp, - fontWeight = FontWeight.Bold, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier - .weight(1f) - .combinedClickable( - onClick = { expanded = true }, - onLongClick = { - val ua = UserAgent.ofAor(selected) - if (ua != null) { - val acc = ua.account - if (Api.account_regint(acc.accp) > 0) { - Api.account_set_regint(acc.accp, 0) - Api.ua_unregister(ua.uap) - } else { - Api.account_set_regint( - acc.accp, - acc.configuredRegInt - ) - Api.ua_register(ua.uap) - } - acc.regint = Api.account_regint(acc.accp) - AccountsActivity.saveAccounts() - } - } - ) - ) - Icon( - imageVector = if (expanded) - Icons.Default.KeyboardArrowUp - else - Icons.Default.KeyboardArrowDown, - contentDescription = null - ) - androidx.compose.material3.DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - uas.value.forEachIndexed { _, ua -> - val acc = ua.account - DropdownMenuItem( - onClick = { - expanded = false - run { - viewModel.updateSelectedAor(acc.aor) - } - showCall(ua) - updateIcons(acc) - }, - text = { Text( - text = acc.text(), - fontSize = 17.sp, - fontWeight = FontWeight.Bold - ) }, - leadingIcon = { - Icon( - imageVector = ImageVector.vectorResource(uasStatus.value[acc.aor]!!), - contentDescription = null, - tint = Color.Unspecified, - ) - } - ) - } - } - } - } - - @Composable - fun CallUriRow() { - val suggestions by remember { contactNames } - var filteredSuggestions by remember { mutableStateOf(suggestions) } - val focusRequester = remember { FocusRequester() } - val lazyListState = rememberLazyListState() - - Row(modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically) { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedTextField( - value = callUri.value, - enabled = callUriEnabled.value, - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - disabledBorderColor = OutlinedTextFieldDefaults.colors().unfocusedIndicatorColor), - onValueChange = { - if (it != callUri.value) { - callUri.value = it - filteredSuggestions = suggestions.filter { suggestion -> - it.length > 2 && suggestion.startsWith(it, ignoreCase = true) - } - showSuggestions.value = it.length > 2 - } - }, - trailingIcon = { - if (callUriEnabled.value && callUri.value.isNotEmpty()) - Icon(Icons.Outlined.Clear, - contentDescription = null, - modifier = Modifier - .clickable { - if (showSuggestions.value) - showSuggestions.value = false - else - callUri.value = "" - } - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 4.dp, end = 4.dp, top = 12.dp, bottom = 2.dp) - .focusRequester(focusRequester) - .onFocusChanged { - val account = Account.ofAor(viewModel.selectedAor.value) - if (account != null) { - dialpad = account.numericKeypad - } - }, - label = { - LabelText( - text = callUriLabel.value, - fontSize = 18.sp, - ) - }, - textStyle = TextStyle( - fontSize = 18.sp, - color = LocalCustomColors.current.itemText - ), - keyboardOptions = if (dialpad) - KeyboardOptions(keyboardType = KeyboardType.Phone) - else - KeyboardOptions(keyboardType = KeyboardType.Text) - ) - Spacer(modifier = Modifier.height(8.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, RoundedCornerShape(8.dp)) - .background( - LocalCustomColors.current.grayLight, - shape = RoundedCornerShape(8.dp) - ) - .animateContentSize() - ) { - if (showSuggestions.value && filteredSuggestions.isNotEmpty()) { - Box(modifier = Modifier - .fillMaxWidth() - .heightIn(max = 150.dp)) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .verticalScrollbar( - state = lazyListState, - color = LocalCustomColors.current.gray - ), - horizontalAlignment = Alignment.Start, - state = lazyListState - ) { - items( - items = filteredSuggestions, - key = { suggestion -> suggestion } - ) { suggestion -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - callUri.value = suggestion - showSuggestions.value = false - } - .padding(12.dp) - ) { - Text( - text = suggestion, - modifier = Modifier.fillMaxWidth(), - color = LocalCustomColors.current.grayDark, - fontSize = 18.sp - ) - } - } - } - } - } - } - } - if (showCallTimer.value) { - val textColor = LocalCustomColors.current.itemText.toArgb() - AndroidView( - factory = { context -> - Chronometer(context).also { callTimer = it - callTimer?.textSize = 16F - callTimer?.setTextColor(textColor) - } - }, - modifier = Modifier.padding(start = 6.dp, - top = 4.dp, - end = if (securityIcon.intValue != -1) 6.dp else 0.dp), - ) - } - if (securityIcon.intValue != -1) { - Icon( - imageVector = ImageVector.vectorResource(securityIcon.intValue), - contentDescription = null, - modifier = Modifier - .size(28.dp) - .padding(top = 4.dp) - .clickable { - when (securityIcon.intValue) { - R.drawable.unlocked -> { - alertTitle.value = getString(R.string.alert) - alertMessage.value = getString(R.string.call_not_secure) - showAlert.value = true - } - - R.drawable.locked_yellow -> { - alertTitle.value = getString(R.string.alert) - alertMessage.value = getString(R.string.peer_not_verified) - showAlert.value = true - } - - R.drawable.locked_green -> { - dialogTitle.value = getString(R.string.info) - dialogMessage.value = getString(R.string.call_is_secure) - positiveText.value = getString(R.string.unverify) - onPositiveClicked.value = { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - val call = ua.currentCall() - if (call != null) { - if (Api.cmd_exec("zrtp_unverify " + call.zid) != 0) - Log.e( - TAG, - "Command 'zrtp_unverify ${call.zid}' failed" - ) - else - securityIcon.intValue = R.drawable.locked_yellow - } - } - negativeText.value = getString(R.string.cancel) - showDialog.value = true - } - } - }, - tint = Color.Unspecified, - ) - } - } - } - - @Composable - fun CallRow(ctx: Context) { - - Row( modifier = Modifier - .fillMaxWidth() - .padding(start = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Absolute.SpaceBetween - ) { - if (showCallButton.value) - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - showSuggestions.value = false - callClick(ctx) - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.call), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - - if (showCancelButton.value) { - Spacer(modifier = Modifier.weight(1f)) - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - showSuggestions.value = false - abandonAudioFocus() - var ua: UserAgent = userAgentOfSelectedAor() - val call = ua.currentCall() - if (call != null) { - val callp = call.callp - Log.d( - TAG, - "AoR ${ua.account.aor} hanging up call $callp with ${callUri.value}" - ) - Api.ua_hangup(ua.uap, callp, 0, "") - } - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.hangup), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - Spacer(modifier = Modifier.width(12.dp)) - } - - if (showHangupButton.value) { - - var ua: UserAgent = userAgentOfSelectedAor() - - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - abandonAudioFocus() - val uaCalls = ua.calls() - if (uaCalls.isNotEmpty()) { - val call = uaCalls.last() - val callp = call.callp - Log.d(TAG, "AoR ${ua.account.aor} hanging up call $callp with ${callUri.value}") - Api.ua_hangup(ua.uap, callp, 0, "") - } - } - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.hangup), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - val aor = ua.account.aor - val call = ua.currentCall() - if (call != null) { - if (call.onhold) { - Log.d( - TAG, - "AoR $aor resuming call ${call.callp} with ${callUri.value}" - ) - call.resume() - call.onhold = false - holdIcon.intValue = R.drawable.call_hold - } else { - Log.d( - TAG, - "AoR $aor holding call ${call.callp} with ${callUri.value}" - ) - call.hold() - call.onhold = true - holdIcon.intValue = R.drawable.resume - } - } - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = holdIcon.intValue), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - - var showTransferDialog by remember { mutableStateOf(false) } - IconButton( - modifier = Modifier.size(48.dp), - enabled = transferButtonEnabled.value, - onClick = { - val call = ua.currentCall() - if (call != null) { - if (call.onHoldCall != null) { - if (!call.executeTransfer()) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = getString(R.string.transfer_failed) - showAlert.value = true - } - } else - showTransferDialog = true - } - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(transferIcon.intValue), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - - if (showTransferDialog) { - val showDialog = remember { mutableStateOf(true) } - val blindChecked = remember { mutableStateOf(true) } - if (showDialog.value) - BasicAlertDialog( - onDismissRequest = { - keyboardController?.hide() - showDialog.value = false - showTransferDialog = false - } - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 0.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = LocalCustomColors.current.cardBackground - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(R.string.call_transfer), - fontSize = 20.sp, - color = LocalCustomColors.current.alert, - ) - var transferUri by remember { mutableStateOf("") } - val suggestions by remember { contactNames } - var filteredSuggestions by remember { mutableStateOf(suggestions) } - val focusRequester = remember { FocusRequester() } - val lazyListState = rememberLazyListState() - OutlinedTextField( - value = transferUri, - singleLine = true, - onValueChange = { - if (it != transferUri) { - transferUri = it - filteredSuggestions = - suggestions.filter { suggestion -> - transferUri.length > 2 && - suggestion.startsWith( - transferUri, - ignoreCase = true - ) - } - showSuggestions.value = transferUri.length > 2 - } - }, - trailingIcon = { - if (transferUri.isNotEmpty()) - Icon( - Icons.Outlined.Clear, - contentDescription = null, - modifier = Modifier.clickable { - if (showSuggestions.value) - showSuggestions.value = false - else - transferUri = "" - } - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding( - start = 4.dp, - end = 4.dp, - top = 12.dp, - bottom = 2.dp - ) - .focusRequester(focusRequester), - label = { LabelText(stringResource(R.string.transfer_destination)) }, - textStyle = TextStyle( - fontSize = 18.sp, - color = LocalCustomColors.current.itemText - ), - keyboardOptions = if (dialpad) - KeyboardOptions(keyboardType = KeyboardType.Phone) - else - KeyboardOptions(keyboardType = KeyboardType.Text) - ) - Spacer(modifier = Modifier.height(8.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .shadow(8.dp, RoundedCornerShape(8.dp)) - .background( - LocalCustomColors.current.grayLight, - shape = RoundedCornerShape(8.dp) - ) - .animateContentSize() - ) { - if (showSuggestions.value && filteredSuggestions.isNotEmpty()) { - Box(modifier = Modifier - .fillMaxWidth() - .heightIn(max = 150.dp)) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .verticalScrollbar( - state = lazyListState, - color = LocalCustomColors.current.gray - ), - horizontalAlignment = Alignment.Start, - state = lazyListState, - ) { - items( - items = filteredSuggestions, - key = { suggestion -> suggestion } - ) { suggestion -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - transferUri = suggestion - showSuggestions.value = false - } - .padding(12.dp) - ) { - Text( - text = suggestion, - modifier = Modifier.fillMaxWidth(), - color = LocalCustomColors.current.grayDark, - fontSize = 18.sp - ) - } - } - } - } - } - } - val call = ua.currentCall() - if (call != null && call.replaces()) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.blind), - color = LocalCustomColors.current.alert, - modifier = Modifier.padding(8.dp), - ) - Switch( - checked = blindChecked.value, - onCheckedChange = { - blindChecked.value = true - } - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.attended), - color = LocalCustomColors.current.alert, - modifier = Modifier.padding(8.dp), - ) - Switch( - checked = !blindChecked.value, - onCheckedChange = { - blindChecked.value = false - } - ) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton( - onClick = { - keyboardController?.hide() - showDialog.value = false - showTransferDialog = false - }, - modifier = Modifier.padding(end = 32.dp), - ) { - Text( - text = stringResource(R.string.cancel), - color = LocalCustomColors.current.gray - ) - } - TextButton( - onClick = { - showSuggestions.value = false - var uriText = transferUri.trim() - if (uriText.isNotEmpty()) { - val uris = Contact.contactUris(uriText) - if (uris.size > 1) { - items.value = uris - itemAction.value = { index -> - val uri = uris[index] - transfer( - ua, - if (Utils.isTelNumber(uri)) "tel:$uri" else uri, - !blindChecked.value - ) - showSelectItemDialog.value = false - } - showSelectItemDialog.value = true - } - else { - if (uris.size == 1) uriText = uris[0] - transfer( - ua, - if (Utils.isTelNumber(uriText)) "tel:$uriText" else uriText, - !blindChecked.value - ) - } - keyboardController?.hide() - showDialog.value = false - showTransferDialog = false - } - }, - modifier = Modifier.padding(end = 16.dp), - ) { - Text( - text = stringResource( - if (blindChecked.value) - R.string.transfer - else - R.string.call - ).uppercase(), - color = LocalCustomColors.current.primary - ) - } - } - } - } - } - } - - val focusRequester = remember { FocusRequester() } - val shouldRequestFocus by focusDtmf - TextField( - value = dtmfText.value, - onValueChange = { - if (it.length > dtmfText.value.length) { - val char = it.last() - if (char.isDigit() || char == '*' || char == '#') { - Log.d(TAG, "Got DTMF digit '$char'") - ua.currentCall()?.sendDigit(char) - } - } - dtmfText.value = it - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - modifier = Modifier - .width(80.dp) - .focusRequester(focusRequester), - enabled = dtmfEnabled.value, - textStyle = TextStyle(fontSize = 16.sp), - label = { LabelText(stringResource(R.string.dtmf)) }, - singleLine = true - ) - LaunchedEffect(shouldRequestFocus) { - if (shouldRequestFocus) { - focusRequester.requestFocus() - focusDtmf.value = false - } - } - - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - val call = ua.currentCall() - val stats = call?.stats("audio") - if (stats != null && call.startTime != null && stats != "") { - val parts = stats.split(",") as java.util.ArrayList - if (parts[2] == "0/0") { - parts[2] = "?/?" - parts[3] = "?/?" - parts[4] = "?/?" - } - val codecs = call.audioCodecs() - val duration = call.duration() - val txCodec = codecs.split(',')[0].split("/") - val rxCodec = codecs.split(',')[1].split("/") - alertTitle.value = getString(R.string.call_info) - alertMessage.value = - "${String.format(getString(R.string.duration), duration)}\n" + - "${getString(R.string.codecs)}: ${txCodec[0]} ch ${txCodec[2]}/" + - "${rxCodec[0]} ch ${rxCodec[2]}\n" + - "${String.format(getString(R.string.rate), parts[0])}\n" + - "${ - String.format( - getString(R.string.average_rate), - parts[1] - ) - }\n" + - "${getString(R.string.packets)}: ${parts[2]}\n" + - "${getString(R.string.lost)}: ${parts[3]}\n" + - String.format(getString(R.string.jitter), parts[4]) - showAlert.value = true - } else { - alertTitle.value = getString(R.string.call_info) - alertMessage.value = getString(R.string.call_info_not_available) - showAlert.value = true - } - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.info), - modifier = Modifier.size(36.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - } - - if (showAnswerRejectButtons.value) { - - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - answer(ctx) - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.call), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - - Spacer(Modifier.weight(1f)) - - IconButton( - modifier = Modifier.size(48.dp), - onClick = { - reject() - }, - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.hangup), - modifier = Modifier.size(48.dp), - tint = Color.Unspecified, - contentDescription = null, - ) - } - } - } - - } - - private fun callClick(ctx: Context) { - if (viewModel.selectedAor.value != "") { - if (Utils.checkPermissions(ctx, arrayOf(RECORD_AUDIO))) { - if (Call.inCall()) - return - val uriText = callUri.value.trim() - if (uriText.isNotEmpty()) { - val uris = Contact.contactUris(uriText) - if (uris.isEmpty()) - makeCall(uriText) - else if (uris.size == 1) - makeCall(uris[0]) - else { - items.value = uris - itemAction.value = { index -> - makeCall(uris[index]) - } - showSelectItemDialog.value = true - } - } - else { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - val latestPeerUri = CallHistoryNew.aorLatestPeerUri(ua.account.aor) - if (latestPeerUri != null) - callUri.value = Utils.friendlyUri(this, latestPeerUri, ua.account) - } - } - else - Toast.makeText(applicationContext, R.string.no_calls, Toast.LENGTH_SHORT).show() - } - } - - private fun makeCall(uriText: String) { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - val aor = ua.account.aor - val peerUri = if (Utils.isTelNumber(uriText)) - "tel:$uriText" - else - uriText - val uri = if (Utils.isTelUri(peerUri)) { - if (ua.account.telProvider == "") { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.no_telephony_provider), aor) - showAlert.value = true - return - } - Utils.telToSip(peerUri, ua.account) - } - else - Utils.uriComplete(peerUri, aor) - if (!Utils.checkUri(uri)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), uri) - showAlert.value = true - } - else if (!BaresipService.requestAudioFocus(applicationContext)) { - Toast.makeText(applicationContext, R.string.audio_focus_denied, - Toast.LENGTH_SHORT).show() - } else { - if (Build.VERSION.SDK_INT < 31) { - Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") - am.mode = AudioManager.MODE_IN_COMMUNICATION - runCall(ua, uri) - } else { - if (am.mode == AudioManager.MODE_IN_COMMUNICATION) { - runCall(ua, uri) - } else { - audioModeChangedListener = AudioManager.OnModeChangedListener { mode -> - if (mode == AudioManager.MODE_IN_COMMUNICATION) { - Log.d(TAG, "Audio mode changed to MODE_IN_COMMUNICATION using " + - "device ${am.communicationDevice!!.type}") - if (audioModeChangedListener != null) { - am.removeOnModeChangedListener(audioModeChangedListener!!) - audioModeChangedListener = null - } - runCall(ua, uri) - } else { - Log.d(TAG, "Audio mode changed to mode ${am.mode} using " + - "device ${am.communicationDevice!!.type}") - } - } - am.addOnModeChangedListener(mainExecutor, audioModeChangedListener!!) - Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") - am.mode = AudioManager.MODE_IN_COMMUNICATION - } - } - } - } - - private fun runCall(ua: UserAgent, uri: String) { - callRunnable = Runnable { - callRunnable = null - if (!call(ua, uri)) { - BaresipService.abandonAudioFocus(applicationContext) - showCallButton.value = true - showCancelButton.value = false - } - else { - showCallButton.value = false - showCancelButton.value = true - } - } - callHandler.postDelayed(callRunnable!!, BaresipService.audioDelay) - } - - private fun answer(ctx: Context) { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - val call = ua.currentCall() - if (call != null) { - Log.d(TAG, "AoR ${ua.account.aor} answering call from ${callUri.value}") - val intent = Intent(ctx, BaresipService::class.java) - intent.action = "Call Answer" - intent.putExtra("uap", ua.uap) - intent.putExtra("callp", call.callp) - intent.putExtra("video", Api.VIDMODE_OFF) - startService(intent) - } - } - - private fun reject() { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - val call = ua.currentCall() - if (call != null) { - val callp = call.callp - Log.d(TAG, "AoR ${ua.account.aor} rejecting call $callp from ${callUri.value}") - call.rejected = true - Api.ua_hangup(ua.uap, callp, 486, "Busy Here") - } - } - - @Composable - fun BottomBar(ctx: Context) { - val buttonSize = 48.dp - Row( modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - if (showVmIcon) - IconButton( - onClick = { - if (viewModel.selectedAor.value != "") { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - val acc = ua.account - if (acc.vmUri != "") { - dialogTitle.value = getString(R.string.voicemail_messages) - dialogMessage.value = acc.vmMessages(ctx) - positiveText.value = getString(R.string.listen) - onPositiveClicked.value = { - val i = Intent(ctx, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - i.putExtra("action", "call") - i.putExtra("uap", ua.uap) - i.putExtra("peer", acc.vmUri) - startActivity(i) - } - negativeText.value = getString(R.string.cancel) - onNegativeClicked.value = {} - showDialog.value = true - } - } - }, - modifier = Modifier - .weight(1f) - .size(buttonSize) - ) { - Icon( - imageVector = ImageVector.vectorResource(vmIcon.intValue), - contentDescription = null, - Modifier.size(buttonSize), - tint = Color.Unspecified - ) - } - - IconButton( - onClick = { - if (viewModel.selectedAor.value != "") { - val i = Intent(ctx, ContactsActivity::class.java) - val b = Bundle() - b.putString("aor", viewModel.selectedAor.value) - i.putExtras(b) - contactsRequest.launch(i) - } - }, - modifier = Modifier - .weight(1f) - .size(buttonSize) - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.contacts), - contentDescription = null, - Modifier.size(buttonSize), - tint = LocalCustomColors.current.secondary - ) - } - - IconButton( - onClick = { - if (viewModel.selectedAor.value != "") { - val i = Intent(this@MainActivity, ChatsActivity::class.java) - val b = Bundle() - b.putString("aor", viewModel.selectedAor.value) - b.putString("peer", resumeUri) - i.putExtras(b) - chatRequests.launch(i) - } - }, - modifier = Modifier - .weight(1f) - .size(buttonSize) - ) { - Icon( - imageVector = ImageVector.vectorResource(messagesIcon.intValue), - contentDescription = null, - Modifier.size(buttonSize), - tint = Color.Unspecified - ) - } - - IconButton( - onClick = { - calls(ctx) - }, - modifier = Modifier - .weight(1f) - .size(buttonSize) - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.calls), - contentDescription = null, - Modifier.size(buttonSize), - tint = Color.Unspecified - ) - } - - IconButton( - onClick = { dialpad = !dialpad }, - modifier = Modifier - .weight(1f) - .size(buttonSize), - enabled = dialpadButtonEnabled - ) { - Icon( - imageVector = if (dialpad) - ImageVector.vectorResource(R.drawable.dialpad_on) - else - ImageVector.vectorResource(R.drawable.dialpad_off), - contentDescription = null, - modifier = Modifier.size(buttonSize), - tint = Color.Unspecified - ) - } - } - - } - - @Composable - fun OnHoldNotice() { - if (showOnHoldNotice.value) - OutlinedButton( - onClick = {}, - border = BorderStroke(1.dp, LocalCustomColors.current.accent), - modifier = Modifier.padding(16.dp), - shape = RoundedCornerShape(20) - ) { - Text( - text = stringResource(R.string.call_is_on_hold), - fontSize = 18.sp, - color = LocalCustomColors.current.itemText, - ) - } - } - - @Composable - fun AskPasswords(ctx: Context) { - if (showPasswordsDialog.value) { - if (passwordAccounts.isNotEmpty()) { - val account = passwordAccounts.removeAt(0) - val params = account.substringAfter(">") - if (Utils.paramValue(params, "auth_user") != "" && Utils.paramValue(params, "auth_pass") == "") { - val aor = account.substringAfter("<").substringBefore(">") - PasswordDialog( - ctx = ctx, - showPasswordDialog = showPasswordsDialog, - password = password, - keyboardController = keyboardController, - title = stringResource(R.string.authentication_password), - message = stringResource(R.string.account) + " " + Utils.plainAor(aor), - okAction = { - if (password.value != "") - BaresipService.aorPasswords[aor] = password.value - showPasswordsDialog.value = true - }, - cancelAction = { - showPasswordsDialog.value = true - } - ) - } - else { - showPasswordsDialog.value = false - showPasswordsDialog.value = true - } - } - else - requestPermissionsLauncher.launch(permissions) - } - } - - @Composable - fun AskPassword(ctx: Context) { - if (showPasswordDialog.value) - PasswordDialog( - ctx = ctx, - showPasswordDialog = showPasswordDialog, - password = password, - keyboardController = keyboardController, - title = passwordTitle, - okAction = { - if (password.value != "") { - if (passwordTitle == getString(R.string.encrypt_password)) - backup(password.value) - else - restore(password.value) - password.value = "" - } - }, - cancelAction = { - if (downloadsOutputUri != null) { - Utils.deleteFile(ctx, downloadsOutputUri!!) - } - } - ) - } - - private fun calls(ctx: Context) { - if (viewModel.selectedAor.value != "") { - val i = Intent(ctx, CallsActivity::class.java) - val b = Bundle() - b.putString("aor", viewModel.selectedAor.value) - i.putExtras(b) - callsRequest.launch(i) - } - } - - private fun updateIcons(acc: Account?) { - if (acc == null) { - showVmIcon = false - messagesIcon.intValue = R.drawable.messages - callsIcon.intValue = R.drawable.calls - } - else { - if (acc.vmUri != "") { - showVmIcon = true - vmIcon.intValue = if (acc.vmNew > 0) - R.drawable.voicemail_new - else - R.drawable.voicemail - } else - showVmIcon = false - messagesIcon.intValue= if (acc.unreadMessages) - R.drawable.messages_unread - else - R.drawable.messages - callsIcon.intValue = if (acc.missedCalls) - R.drawable.calls_missed - else - R.drawable.calls - } - } - override fun onStart() { super.onStart() - Log.e(TAG, "Main onStart") + Log.i(TAG, "Main onStart") val action = intent.getStringExtra("action") if (action != null) { // MainActivity was not visible when call, message, or transfer request came in intent.removeExtra("action") - handleIntent(intent, action) + handleIntent(applicationContext, viewModel, intent, action) } } override fun onResume() { super.onResume() - Log.d(TAG, "Main onResume with action '$resumeAction'") + Log.d(TAG, "Main onResume") nm.cancelAll() - BaresipService.isMainVisible = true - when (resumeAction) { - "call show" -> { - handleServiceEvent ("call incoming", - arrayListOf(resumeCall!!.ua.uap, resumeCall!!.callp)) - } - "call answer" -> { - answer(this@MainActivity) - showCall(resumeCall!!.ua) - } - "call missed" -> { - calls(this@MainActivity) - } - "call reject" -> - reject() - "call" -> { - callUri.value = Account.ofAor(viewModel.selectedAor.value)!!.resumeUri - callClick(this@MainActivity) - } - "dial" -> { - callUri.value = Account.ofAor(viewModel.selectedAor.value)!!.resumeUri - } - "call transfer", "transfer show", "transfer accept" -> - handleServiceEvent("$resumeAction,$resumeUri", - arrayListOf(resumeCall!!.ua.uap, resumeCall!!.callp)) - "message", "message show", "message reply" -> - handleServiceEvent(resumeAction, arrayListOf(resumeUap, resumeUri)) - else -> { - val incomingCall = Call.call("incoming") - if (incomingCall != null) { - spinToAor(incomingCall.ua.account.aor) - } else { - restoreActivities() - if (uas.value.isNotEmpty()) { - if (viewModel.selectedAor.value == "") { - if (Call.inCall()) - spinToAor(Call.calls()[0].ua.account.aor) - else - spinToAor(uas.value.first().account.aor) - } - } - } - val ua = UserAgent.ofAor(viewModel.selectedAor.value) - if (ua != null) { - showCall(ua) - updateIcons(ua.account) - } - } - } - resumeAction = "" } override fun onPause() { super.onPause() Log.d(TAG, "Main onPause") - Utils.addActivity("main") - BaresipService.isMainVisible = false - callTimer?.stop() - saveCallUri() - } - - override fun onStop() { - super.onStop() - Log.d(TAG, "Main onStop") } override fun onDestroy() { Log.d(TAG, "Main onDestroy") - if (Build.VERSION.SDK_INT >= 33) { - if (backInvokedCallback != null) - onBackInvokedDispatcher.unregisterOnBackInvokedCallback(backInvokedCallback!!) - } - else - onBackPressedCallback.remove() - this.unregisterReceiver(screenEventReceiver) if (Build.VERSION.SDK_INT >= 31) @@ -2176,17 +353,10 @@ class MainActivity : ComponentActivity() { BaresipService.serviceEvent.removeObserver(serviceEventObserver) BaresipService.serviceEvents.clear() - BaresipService.activities.clear() super.onDestroy() } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - if (dtmfEnabled.value) - focusDtmf.value = true - } - override fun onNewIntent(intent: Intent) { // Called when MainActivity already exists at the top of current task super.onNewIntent(intent) @@ -2194,141 +364,26 @@ class MainActivity : ComponentActivity() { this.setShowWhenLocked(true) this.setTurnScreenOn(true) - resumeAction = "" - resumeUri = "" - Log.d(TAG, "onNewIntent with action/data '${intent.action}/${intent.data}'") when (intent.action) { ACTION_DIAL, ACTION_CALL, ACTION_VIEW -> - callAction(intent.data, if (intent.action == ACTION_CALL) "call" else "dial") + callAction( + applicationContext, + viewModel, + intent.data, + if (intent.action == ACTION_CALL) "call" else "dial" + ) else -> { val action = intent.getStringExtra("action") if (action != null) { intent.removeExtra("action") - handleIntent(intent, action) + handleIntent(applicationContext, viewModel, intent, action) } } } } - private fun callAction(uri: Uri?, action: String) { - if (Call.inCall() || uas.value.isEmpty()) - return - Log.d(TAG, "Action $action to $uri") - if (uri != null) { - when (uri.scheme) { - "sip" -> { - val uriStr = Utils.uriUnescape(uri.toString()) - var ua = UserAgent.ofDomain(Utils.uriHostPart(uriStr)) - if (ua == null && uas.value.isNotEmpty()) - ua = uas.value[0] - if (ua == null) { - Log.w(TAG, "No accounts for '$uriStr'") - return - } - spinToAor(ua.account.aor) - resumeAction = action - ua.account.resumeUri = uriStr - } - "tel" -> { - val uriStr = uri.toString().replace("%2B", "+") - .replace("%20", "") - .filterNot{setOf('-', ' ', '(', ')').contains(it)} - var account: Account? = null - for (a in Account.accounts()) - if (a.telProvider != "") { - account = a - break - } - if (account == null) { - Log.w(TAG, "No telephony providers for '$uriStr'") - return - } - spinToAor(account.aor) - resumeAction = action - account.resumeUri = uriStr - } - else -> { - Log.w(TAG, "Unsupported URI scheme ${uri.scheme}") - return - } - } - } - } - - private fun handleIntent(intent: Intent, action: String) { - Log.d(TAG, "Handling intent '$action'") - val ev = action.split(",") - when (ev[0]) { - "call", "dial" -> { - if (Call.inCall()) { - Toast.makeText(applicationContext, getString(R.string.call_already_active), - Toast.LENGTH_SHORT).show() - return - } - val uap = intent.getLongExtra("uap", 0L) - val ua = UserAgent.ofUap(uap) - if (ua == null) { - Log.w(TAG, "handleIntent 'call' did not find ua $uap") - return - } - spinToAor(ua.account.aor) - resumeAction = action - ua.account.resumeUri = intent.getStringExtra("peer")!! - } - "call show", "call answer" -> { - val callp = intent.getLongExtra("callp", 0L) - val call = Call.ofCallp(callp) - if (call == null) { - Log.w(TAG, "handleIntent '$action' did not find call $callp") - return - } - val ua = call.ua - spinToAor(ua.account.aor) - resumeAction = action - resumeCall = call - } - "call missed" -> { - val uap = intent.getLongExtra("uap", 0L) - val ua = UserAgent.ofUap(uap) - if (ua == null) { - Log.w(TAG, "handleIntent did not find ua $uap") - return - } - spinToAor(ua.account.aor) - resumeAction = action - } - "call transfer", "transfer show", "transfer accept" -> { - val callp = intent.getLongExtra("callp", 0L) - val call = Call.ofCallp(callp) - if (call == null) { - Log.w(TAG, "handleIntent '$action' did not find call $callp") - moveTaskToBack(true) - return - } - resumeAction = ev[0] - resumeCall = call - resumeUri = if (ev[0] == "call transfer") - ev[1] - else - intent.getStringExtra("uri")!! - } - "message", "message show", "message reply" -> { - val uap = intent.getLongExtra("uap", 0L) - val ua = UserAgent.ofUap(uap) - if (ua == null) { - Log.w(TAG, "handleIntent did not find ua $uap") - return - } - spinToAor(ua.account.aor) - resumeAction = action - resumeUap = uap - resumeUri = intent.getStringExtra("peer")!! - } - } - } - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { val stream = if (am.mode == AudioManager.MODE_RINGTONE) AudioManager.STREAM_RING @@ -2348,315 +403,6 @@ class MainActivity : ComponentActivity() { return super.onKeyDown(keyCode, event) } - private fun handleServiceEvent(event: String, params: ArrayList) { - - fun handleNextEvent(logMessage: String? = null) { - if (logMessage != null) - Log.w(TAG, logMessage) - if (BaresipService.serviceEvents.isNotEmpty()) { - val first = BaresipService.serviceEvents.removeAt(0) - handleServiceEvent(first.event, first.params) - } - } - - if (taskId == -1) { - handleNextEvent("Omit service event '$event' for task -1") - return - } - - if (event == "started") { - val uriString = params[0] as String - Log.d(TAG, "Handling service event 'started' with URI '$uriString'") - if (!initialized) { - // Android has restarted baresip when permission has been denied in app settings - recreate() - return - } - if (uriString != "") - callAction(uriString.toUri(), "dial") - else { - if (viewModel.selectedAor.value == "" && uas.value.isNotEmpty()) - viewModel.updateSelectedAor(uas.value.first().account.aor) - } - if (Preferences(applicationContext).displayTheme != AppCompatDelegate.getDefaultNightMode()) { - AppCompatDelegate.setDefaultNightMode(Preferences(applicationContext).displayTheme) - } - handleNextEvent() - return - } - - if (event == "stopped") { - Log.d(TAG, "Handling service event 'stopped' with start error '${params[0]}'") - if (params[0] != "") { - alertTitle.value = getString(R.string.notice) - alertMessage.value = getString(R.string.start_failed) - showAlert.value = true - } - else { - finishAndRemoveTask() - if (restart) - reStart() - else - exitProcess(0) - } - return - } - - val uap = params[0] as Long - val ua = UserAgent.ofUap(uap) - if (ua == null) { - handleNextEvent("handleServiceEvent '$event' did not find ua $uap") - return - } - - val ev = event.split(",") - Log.d(TAG, "Handling service event '${ev[0]}' for $uap") - val acc = ua.account - val aor = ua.account.aor - - when (ev[0]) { - "call rejected" -> { - if (aor == viewModel.selectedAor.value) - callsIcon.intValue = R.drawable.calls_missed - } - "call incoming", "call outgoing" -> { - val callp = params[1] as Long - if (BaresipService.isMainVisible) { - spinToAor(aor) - showCall(ua, Call.ofCallp(callp)) - } else { - Log.d(TAG, "Reordering to front") - BaresipService.activities.clear() - BaresipService.serviceEvents.clear() - val i = Intent(applicationContext, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - i.putExtra("action", "call show") - i.putExtra("callp", callp) - startActivity(i) - return - } - } - "call answered" -> { - showCall(ua) - } - "call redirect" -> { - val redirectUri = ev[1] - val target = Utils.friendlyUri(this, redirectUri, acc) - if (acc.autoRedirect) { - redirect(ua, target) - Toast.makeText(applicationContext, - String.format(getString(R.string.redirect_notice), target), - Toast.LENGTH_SHORT - ).show() - } - else { - dialogTitle.value = getString(R.string.redirect_request) - dialogMessage.value = String.format(getString(R.string.redirect_request_query), target) - positiveText.value = getString(R.string.yes) - onPositiveClicked.value = { - redirect(ua, target) - } - negativeText.value = getString(R.string.no) - onNegativeClicked.value = {} - showDialog.value = true - } - showCall(ua) - } - "call established" -> { - if (aor == viewModel.selectedAor.value) { - dtmfText.value = "" - showCall(ua) - } - } - "call update" -> { - showCall(ua) - } - "call verify" -> { - val callp = params[1] as Long - val call = Call.ofCallp(callp) - if (call == null) { - handleNextEvent("Call $callp to be verified is not found") - return - } - dialogTitle.value = getString(R.string.verify) - dialogMessage.value = String.format(getString(R.string.verify_sas), ev[1]) - positiveText.value = getString(R.string.yes) - onPositiveClicked.value = { - call.security = if (Api.cmd_exec("zrtp_verify ${ev[2]}") != 0) { - Log.e(TAG, "Command 'zrtp_verify ${ev[2]}' failed") - R.drawable.locked_yellow - } else { - R.drawable.locked_green - } - call.zid = ev[2] - if (aor == viewModel.selectedAor.value) - securityIcon.intValue = call.security - } - negativeText.value = getString(R.string.no) - onNegativeClicked.value = { - call.security = R.drawable.locked_yellow - call.zid = ev[2] - if (aor == viewModel.selectedAor.value) - securityIcon.intValue = R.drawable.locked_yellow - onNegativeClicked.value = {} - } - showDialog.value = true - } - "call verified", "call secure" -> { - val callp = params[1] as Long - val call = Call.ofCallp(callp) - if (call == null) { - handleNextEvent("Call $callp that is verified is not found") - return - } - if (aor == viewModel.selectedAor.value) - securityIcon.intValue = call.security - } - "call transfer", "transfer show" -> { - val callp = params[1] as Long - if (!BaresipService.isMainVisible) { - Log.d(TAG, "Reordering to front") - BaresipService.activities.clear() - BaresipService.serviceEvents.clear() - val i = Intent(applicationContext, MainActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - i.putExtra("action", event) - i.putExtra("callp", callp) - startActivity(i) - return - } - val call = Call.ofCallp(callp) - val target = Utils.friendlyUri(this, ev[1], acc) - dialogTitle.value = if (call != null) - getString(R.string.transfer_request) - else - getString(R.string.call_request) - dialogMessage.value = if (call != null) - String.format(getString(R.string.transfer_request_query), target) - else - String.format(getString(R.string.call_request_query), target) - positiveText.value = getString(R.string.yes) - onPositiveClicked.value = { - if (call in Call.calls()) - acceptTransfer(ua, call!!, ev[1]) - else - makeCall(ev[1]) - } - negativeText.value = getString(R.string.no) - onNegativeClicked.value = { - if (call in Call.calls()) - call!!.notifySipfrag(603, "Decline") - onNegativeClicked.value = {} - } - showDialog.value = true - } - "transfer accept" -> { - val callp = params[1] as Long - val call = Call.ofCallp(callp) - if (call in Call.calls()) - Api.ua_hangup(uap, callp, 0, "") - call(ua, ev[1]) - showCall(ua) - } - "transfer failed" -> { - showCall(ua) - } - "call closed" -> { - val call = ua.currentCall() - if (call != null) { - call.resume() - startCallTimer(call) - } - else - callTimer?.stop() - if (aor == viewModel.selectedAor.value) { - ua.account.resumeUri = "" - showCall(ua) - if (acc.missedCalls) - callsIcon.intValue = R.drawable.calls_missed - } - if (kgm.isDeviceLocked) - this.setShowWhenLocked(false) - } - "message", "message show", "message reply" -> { - val i = Intent(applicationContext, ChatActivity::class.java) - i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - val b = Bundle() - b.putString("aor", aor) - b.putString("peer", params[1] as String) - b.putBoolean("focus", ev[0] == "message reply") - i.putExtras(b) - chatRequests.launch(i) - } - "mwi notify" -> { - val lines = ev[1].split("\n") - for (line in lines) { - if (line.startsWith("Voice-Message:")) { - val counts = (line.split(" ")[1]).split("/") - acc.vmNew = counts[0].toInt() - acc.vmOld = counts[1].toInt() - break - } - } - if (aor == viewModel.selectedAor.value) { - vmIcon.intValue = if (acc.vmNew > 0) - R.drawable.voicemail_new - else - R.drawable.voicemail - } - } - else -> Log.e(TAG, "Unknown event '${ev[0]}'") - } - - handleNextEvent() - } - - private fun redirect(ua: UserAgent, redirectUri: String) { - if (ua.account.aor != viewModel.selectedAor.value) - spinToAor(ua.account.aor) - callUri.value = redirectUri - callClick(this@MainActivity) - } - - private fun reStart() { - Log.d(TAG, "Trigger restart") - val pm = applicationContext.packageManager - val intent = pm.getLaunchIntentForPackage(this.packageName) - this.startActivity(intent) - exitProcess(0) - } - - @RequiresApi(29) - private fun pickupFileFromDownloads(action: String) { - when (action) { - "backup" -> { - backupRequest.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/octet-stream" - putExtra(Intent.EXTRA_TITLE, "baresip_" + - SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault()).format(Date())) - putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI) - }) - } - "restore" -> { - restoreRequest.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/octet-stream" - putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI) - }) - } - "logcat" -> { - logcatRequest.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "text/plain" - putExtra(Intent.EXTRA_TITLE, "baresip_logcat_" + - SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault()).format(Date())) - putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI) - }) - } - } - } - private fun quitRestart(reStart: Boolean) { Log.i(TAG, "quitRestart Restart = $reStart") window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, @@ -2668,453 +414,18 @@ class MainActivity : ComponentActivity() { } else { finishAndRemoveTask() if (reStart) - reStart() + quitRestart(true) else exitProcess(0) } } - private fun transfer(ua: UserAgent, uriText: String, attended: Boolean) { - val uri = if (Utils.isTelUri(uriText)) - Utils.telToSip(uriText, ua.account) - else - Utils.uriComplete(uriText, ua.account.aor) - if (!Utils.checkUri(uri)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = String.format(getString(R.string.invalid_sip_or_tel_uri), uri) - showAlert.value = true - } - else { - val call = ua.currentCall() - if (call != null) { - if (attended) { - if (call.hold()) { - call.referTo = uri - call(ua, uri, call) - } - } - else { - if (!call.transfer(uri)) { - alertTitle.value = getString(R.string.notice) - alertMessage.value = getString(R.string.transfer_failed) - showAlert.value = true - } - } - showCall(ua) - } - } - } - - private fun startBaresip() { - if (!BaresipService.isStartReceived) { - baresipService.action = "Start" - startService(baresipService) - if (atStartup) - moveTaskToBack(true) - } - } - - private fun backup(password: String) { - val files = arrayListOf("accounts", "config", "contacts", "call_history", - "messages", "gzrtp.zid", "cert.pem", "ca_cert", "ca_certs.crt") - File(BaresipService.filesPath).walk().forEach { - if (it.name.endsWith(".png")) - files.add(it.name) - } - val zipFile = getString(R.string.app_name) + ".zip" - val zipFilePath = BaresipService.filesPath + "/$zipFile" - if (!Utils.zip(files, zipFile)) { - Log.w(TAG, "Failed to write zip file '$zipFile'") - alertTitle.value = getString(R.string.error) - alertMessage.value = String.format(getString(R.string.backup_failed), - Utils.fileNameOfUri(applicationContext, downloadsOutputUri!!)) - showAlert.value = true - downloadsOutputUri = null - return - } - val content = Utils.getFileContents(zipFilePath) - if (content == null) { - Log.w(TAG, "Failed to read zip file '$zipFile'") - alertTitle.value = getString(R.string.error) - alertMessage.value = String.format(getString(R.string.backup_failed), - Utils.fileNameOfUri(applicationContext, downloadsOutputUri!!)) - showAlert.value = true - downloadsOutputUri = null - return - } - if (!Utils.encryptToUri(applicationContext, downloadsOutputUri!!, content, password)) { - alertTitle.value = getString(R.string.error) - alertMessage.value = String.format(getString(R.string.backup_failed), - Utils.fileNameOfUri(applicationContext, downloadsOutputUri!!)) - showAlert.value = true - downloadsOutputUri = null - return - } - alertTitle.value = getString(R.string.info) - alertMessage.value = String.format(getString(R.string.backed_up), - Utils.fileNameOfUri(applicationContext, downloadsOutputUri!!)) - showAlert.value = true - Utils.deleteFile(File(zipFilePath)) - downloadsOutputUri = null - } - - private fun restore(password: String) { - val zipFile = getString(R.string.app_name) + ".zip" - val zipFilePath = BaresipService.filesPath + "/$zipFile" - val zipData = Utils.decryptFromUri(applicationContext, downloadsInputUri!!, password) - if (zipData == null) { - alertTitle.value = getString(R.string.error) - alertMessage.value = String.format(getString(R.string.restore_failed), - Utils.fileNameOfUri(applicationContext, downloadsOutputUri!!)) - showAlert.value = true - downloadsOutputUri = null - return - } - if (!Utils.putFileContents(zipFilePath, zipData)) { - Log.w(TAG, "Failed to write zip file '$zipFile'") - alertTitle.value = getString(R.string.error) - alertMessage.value = String.format(getString(R.string.restore_failed), - Utils.fileNameOfUri(applicationContext, downloadsOutputUri!!)) - showAlert.value = true - downloadsOutputUri = null - return - } - if (!Utils.unZip(zipFilePath)) { - Log.w(TAG, "Failed to unzip file '$zipFile'") - alertTitle.value = getString(R.string.error) - alertMessage.value = String.format(getString(R.string.restore_unzip_failed), "baresip", BuildConfig.VERSION_NAME) - showAlert.value = true - downloadsOutputUri = null - return - } - Utils.deleteFile(File(zipFilePath)) - - File("${BaresipService.filesPath}/recordings").walk().forEach { - if (it.name.startsWith("dump")) - Utils.deleteFile(it) - } - - dialogTitle.value = getString(R.string.info) - dialogMessage.value = getString(R.string.restored) - positiveText.value = getString(R.string.restart) - onPositiveClicked.value = { - quitRestart(true) - } - negativeText.value = getString(R.string.cancel) - onNegativeClicked.value = {} - showDialog.value = true - - downloadsOutputUri = null - } - - private fun spinToAor(aor: String) { - if (aor != viewModel.selectedAor.value) - viewModel.updateSelectedAor(aor) - updateIcons(Account.ofAor(aor)) - } - - private fun userAgentOfSelectedAor(): UserAgent { - return UserAgent.ofAor(viewModel.selectedAor.value)!! - } - - private fun call(ua: UserAgent, uri: String, onHoldCall: Call? = null): Boolean { - spinToAor(ua.account.aor) - val callp = ua.callAlloc(0L, Api.VIDMODE_OFF) - return if (callp != 0L) { - Log.d(TAG, "Adding outgoing call ${ua.uap}/$callp/$uri") - val call = Call(callp, ua, uri, "out", "outgoing") - call.onHoldCall = onHoldCall - call.add() - if (onHoldCall != null) - onHoldCall.newCall = call - if (call.connect(uri)) { - showCall(ua) - true - } else { - Log.w(TAG, "call_connect $callp failed") - if (onHoldCall != null) - onHoldCall.newCall = null - call.remove() - call.destroy() - showCall(ua) - false - } - } else { - Log.w(TAG, "callAlloc for ${ua.uap} to $uri failed") - false - } - } - - private fun acceptTransfer(ua: UserAgent, call: Call, uri: String) { - val newCallp = ua.callAlloc(call.callp, Api.VIDMODE_OFF) - if (newCallp != 0L) { - Log.d(TAG, "Adding outgoing call ${ua.uap}/$newCallp/$uri") - val newCall = Call(newCallp, ua, uri, "out", "transferring") - newCall.add() - if (newCall.connect(uri)) { - if (ua.account.aor != viewModel.selectedAor.value) - spinToAor(ua.account.aor) - showCall(ua) - } else { - Log.w(TAG, "call_connect $newCallp failed") - call.notifySipfrag(500, "Call Error") - } - } else { - Log.w(TAG, "callAlloc for ua ${ua.uap} call ${call.callp} transfer failed") - call.notifySipfrag(500, "Call Error") - } - } - - private fun showCall(ua: UserAgent?, showCall: Call? = null) { - if (ua == null) - return - val call = showCall ?: ua.currentCall() - if (call == null) { - pullToRefreshEnabled = true - if (ua.account.resumeUri != "") - callUri.value = ua.account.resumeUri - else - callUri.value = "" - callUriLabel.value = getString(R.string.outgoing_call_to_dots) - callUriEnabled.value = true - keyboardController?.hide() - showCallTimer.value = false - securityIcon.intValue = -1 - showHangupButton.value = false - transferIcon.intValue = R.drawable.call_transfer - dtmfEnabled.value = false - focusDtmf.value = false - showCallButton.value = true - showCancelButton.value = false - showAnswerRejectButtons.value = false - showOnHoldNotice.value = false - dialpadButtonEnabled = true - if (BaresipService.isMicMuted) { - BaresipService.isMicMuted = false - micIcon = R.drawable.mic_on - } - } else { - pullToRefreshEnabled = false - callUriEnabled.value = false - when (call.status) { - "outgoing", "transferring", "answered" -> { - callUriLabel.value = if (call.status == "answered") - getString(R.string.incoming_call_from_dots) - else - getString(R.string.outgoing_call_to_dots) - callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) - showCallTimer.value = false - securityIcon.intValue = -1 - showCallButton.value = false - showCancelButton.value = call.status == "outgoing" - showHangupButton.value = !showCancelButton.value - showAnswerRejectButtons.value = false - showOnHoldNotice.value = false - dialpadButtonEnabled = false - } - "incoming" -> { - showCallTimer.value = false - securityIcon.intValue = -1 - val uri = call.diverterUri() - if (uri != "") { - callUriLabel.value = getString(R.string.diverted_by_dots) - callUri.value = Utils.friendlyUri(this, uri, ua.account) - } - else { - callUriLabel.value = getString(R.string.incoming_call_from_dots) - callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) - } - showCallButton.value = false - showCancelButton.value = false - showHangupButton.value = false - showAnswerRejectButtons.value = true - showOnHoldNotice.value = false - dialpadButtonEnabled = false - } - "connected" -> { - if (call.referTo != "") { - callUriLabel.value = getString(R.string.outgoing_call_to_dots) - callUri.value = Utils.friendlyUri(this, call.referTo, ua.account) - transferButtonEnabled.value = false - } else { - if (call.dir == "out") { - callUriLabel.value = getString(R.string.outgoing_call_to_dots) - callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) - } else { - callUriLabel.value = getString(R.string.incoming_call_from_dots) - callUri.value = Utils.friendlyUri(this, call.peerUri, ua.account) - } - transferButtonEnabled.value = true - } - transferIcon.intValue = if (call.onHoldCall == null) - R.drawable.call_transfer - else - R.drawable.call_transfer_execute - showCallTimer.value = true - startCallTimer(call) - if (ua.account.mediaEnc == "") - securityIcon.intValue = -1 - else - securityIcon.intValue = call.security - showCallButton.value = false - showCancelButton.value = false - showHangupButton.value = true - showAnswerRejectButtons.value = false - if (call.onhold) - holdIcon.intValue = R.drawable.resume - else - holdIcon.intValue = R.drawable.call_hold - Handler(Looper.getMainLooper()).postDelayed({ - showOnHoldNotice.value = call.held - }, 100) - if (call.held) { - keyboardController?.hide() - dtmfEnabled.value = false - focusDtmf.value = false - } else { - dtmfEnabled.value = true - focusDtmf.value = true - if (resources.configuration.orientation == ORIENTATION_PORTRAIT) - keyboardController?.show() - } - } - } - } - } - - private fun restoreActivities() { - if (BaresipService.activities.isEmpty()) return - Log.d(TAG, "Activity stack ${BaresipService.activities}") - val activity = BaresipService.activities[0].split(",") - BaresipService.activities.removeAt(0) - when (activity[0]) { - "main" -> { - if (!Call.inCall() && (BaresipService.activities.size > 1)) - restoreActivities() - } - "config" -> { - configRequest.launch(Intent(this, ConfigActivity::class.java)) - } - "audio" -> { - startActivity(Intent(this, AudioActivity::class.java)) - } - "accounts" -> { - val i = Intent(this, AccountsActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - i.putExtras(b) - accountsRequest.launch(i) - } - "account" -> { - val i = Intent(this, AccountActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - i.putExtras(b) - accountsRequest.launch(i) - } - "codecs" -> { - val i = Intent(this, CodecsActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - b.putString("media", activity[2]) - i.putExtras(b) - startActivity(i) - } - "about" -> { - startActivity(Intent(this, AboutActivity::class.java)) - } - "contacts" -> { - val i = Intent(this, ContactsActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - i.putExtras(b) - contactsRequest.launch(i) - } - "contact" -> { - val i = Intent(this, BaresipContactActivity::class.java) - val b = Bundle() - if (activity[1] == "true") { - b.putBoolean("new", true) - b.putString("uri", activity[2]) - } else { - b.putBoolean("new", false) - b.putInt("index", activity[2].toInt()) - } - i.putExtras(b) - startActivity(i) - } - "chats" -> { - val i = Intent(this, ChatsActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - i.putExtras(b) - chatRequests.launch(i) - } - "chat" -> { - val i = Intent(this, ChatActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - b.putString("peer", activity[2]) - b.putBoolean("focus", activity[3] == "true") - i.putExtras(b) - chatRequests.launch(i) - } - "calls" -> { - val i = Intent(this, CallsActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - i.putExtras(b) - callsRequest.launch(i) - } - "call_details" -> { - val i = Intent(this, CallDetailsActivity::class.java) - val b = Bundle() - b.putString("aor", activity[1]) - b.putString("peer", activity[2]) - b.putInt("position", activity[3].toInt()) - i.putExtras(b) - callsRequest.launch(i) - } - } - return - } - - private fun saveCallUri() { - if (uas.value.isNotEmpty() && viewModel.selectedAor.value != "") { - val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! - if (ua.calls().isEmpty()) - ua.account.resumeUri = callUri.value - else - ua.account.resumeUri = "" - } - } - - private fun startCallTimer(call: Call) { - Handler(Looper.getMainLooper()).postDelayed({ - callTimer?.stop() - callTimer?.base = SystemClock.elapsedRealtime() - (call.duration() * 1000L) - callTimer?.start() - }, 100) - } - - private fun abandonAudioFocus() { - if (Build.VERSION.SDK_INT < 31) { - if (callRunnable != null) { - callHandler.removeCallbacks(callRunnable!!) - callRunnable = null - BaresipService.abandonAudioFocus(applicationContext) - } - } else { - if (audioModeChangedListener != null) { - am.removeOnModeChangedListener(audioModeChangedListener!!) - audioModeChangedListener = null - BaresipService.abandonAudioFocus(applicationContext) - } - } - } - - companion object { - var activityAor = "" + private fun reStart() { + Log.d(TAG, "Trigger restart") + val pm = applicationContext.packageManager + val intent = pm.getLaunchIntentForPackage(this.packageName) + this.startActivity(intent) + exitProcess(0) } init { diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt new file mode 100644 index 00000000..6767833c --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -0,0 +1,2617 @@ +package com.tutpro.baresip + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.RECORD_AUDIO +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.app.Activity.RESULT_OK +import android.content.Context +import android.content.Intent +import android.content.res.Configuration.ORIENTATION_PORTRAIT +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.os.SystemClock +import android.provider.DocumentsContract +import android.provider.MediaStore +import android.widget.Chronometer +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +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.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import com.tutpro.baresip.BaresipService.Companion.contactNames +import com.tutpro.baresip.BaresipService.Companion.uas +import com.tutpro.baresip.BaresipService.Companion.uasStatus +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.DropdownMenu +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.PasswordDialog +import com.tutpro.baresip.CustomElements.SelectableAlertDialog +import com.tutpro.baresip.CustomElements.verticalScrollbar +import kotlinx.coroutines.delay +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private var dialpadButtonEnabled by mutableStateOf(true) +private var pullToRefreshEnabled by mutableStateOf(true) + +private var downloadsInputUri: Uri? = null +private var downloadsOutputUri: Uri? = null + +private val passwordTitle = mutableStateOf("") +private val showPasswordDialog = mutableStateOf(false) +private val showPasswordsDialog = mutableStateOf(false) + +private var passwordAccounts = mutableListOf() +private var password = mutableStateOf("") + +private val selectItems = mutableStateOf(listOf()) +private val selectItemAction = mutableStateOf<(Int) -> Unit>({ _ -> run {} }) +private val showSelectItemDialog = mutableStateOf(false) + +private var keyboardController: SoftwareKeyboardController? = null +private var callRunnable: Runnable? = null +private var callHandler: Handler = Handler(Looper.getMainLooper()) +private var audioModeChangedListener: AudioManager.OnModeChangedListener? = null + +fun NavGraphBuilder.mainScreenRoute( + navController: NavController, + viewModel: ViewModel, + onRequestPermissions: () -> Unit, + onRestartApp: () -> Unit, + onQuitApp: () -> Unit +) { + composable("main") { + MainScreen( + navController = navController, + viewModel = viewModel, + onRequestPermissions = onRequestPermissions, + onRestartClick = onRestartApp, + onQuitClick = onQuitApp + ) + } +} + +@Composable +private fun MainScreen( + navController: NavController, + viewModel: ViewModel, + onRequestPermissions: () -> Unit, + onRestartClick: () -> Unit, + onQuitClick: () -> Unit +) { + val ctx = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + Log.d(TAG, "Resumed to MainScreen") + BaresipService.isMainVisible = true + val incomingCall = Call.call("incoming") + if (incomingCall != null) + spinToAor(viewModel, incomingCall.ua.account.aor) + else { + if (uas.value.isNotEmpty()) { + if (viewModel.selectedAor.value == "") { + if (Call.inCall()) + spinToAor(viewModel, Call.calls()[0].ua.account.aor) + else + spinToAor(viewModel, uas.value.first().account.aor) + } + } + } + val ua = UserAgent.ofAor(viewModel.selectedAor.value) + if (ua != null) { + showCall(ctx, viewModel, ua) + updateIcons(viewModel, ua.account) + } + } + Lifecycle.Event.ON_PAUSE -> { + Log.d(TAG, "Paused from MainScreen") + BaresipService.isMainVisible = false + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + Log.d(TAG, "onDispose for MainScreen") + lifecycleOwner.lifecycle.removeObserver(observer) + BaresipService.isMainVisible = false + } + } + + val backupRequestLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.also { uri -> + downloadsOutputUri = uri + passwordTitle.value = ctx.getString(R.string.encrypt_password) + showPasswordDialog.value = true + } + } + } + + @RequiresApi(29) + fun launchBackupRequest() { + if (Build.VERSION.SDK_INT < 29) { + if (!Utils.checkPermissions(ctx, arrayOf(WRITE_EXTERNAL_STORAGE))) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.no_backup) + showAlert.value = true + } + else { + val path = Utils.downloadsPath("baresip.bs") + downloadsOutputUri = File(path).toUri() + passwordTitle.value = ctx.getString(R.string.encrypt_password) + showPasswordDialog.value = true + } + } + else { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + putExtra( + Intent.EXTRA_TITLE, + "baresip_" + SimpleDateFormat( + "yyyy_MM_dd_HH_mm_ss", + Locale.getDefault() + ).format(Date()) + ) + putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI) + } + backupRequestLauncher.launch(intent) + } + } + + val restoreRequestLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.also { uri -> + downloadsInputUri = uri + passwordTitle.value = ctx.getString(R.string.decrypt_password) + showPasswordDialog.value = true + } + } + } + + @RequiresApi(29) + fun launchRestoreRequest() { + if (Build.VERSION.SDK_INT < 29) { + if (!Utils.checkPermissions(ctx, arrayOf(READ_EXTERNAL_STORAGE))) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.no_restore) + showAlert.value = true + } + else { + val path = Utils.downloadsPath("baresip.bs") + downloadsInputUri = File(path).toUri() + passwordTitle.value = ctx.getString(R.string.decrypt_password) + showPasswordDialog.value = true + } + } + else { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/octet-stream" + putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI) + } + restoreRequestLauncher.launch(intent) + } + } + + val logcatRequestLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) + result.data?.data?.also { uri -> + try { + val out = ctx.contentResolver.openOutputStream(uri) + val process = Runtime.getRuntime().exec("logcat -d --pid=${Process.myPid()}") + val bufferedReader = process.inputStream.bufferedReader() + bufferedReader.forEachLine { line -> + out!!.write(line.toByteArray()) + out.write('\n'.code.toByte().toInt()) + } + out!!.close() + } catch (e: Exception) { + Log.e(TAG, "Failed to write logcat to file: $e") + } + } + } + + fun launchLogcatRequest() { + if (Build.VERSION.SDK_INT >= 29) { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plain" + putExtra( + Intent.EXTRA_TITLE, + "baresip_logcat_" + SimpleDateFormat( + "yyyy_MM_dd_HH_mm_ss", + Locale.getDefault() + ).format(Date()) + ) + putExtra(DocumentsContract.EXTRA_INITIAL_URI, MediaStore.Downloads.EXTERNAL_CONTENT_URI) + } + logcatRequestLauncher.launch(intent) + } + } + + if (showPasswordDialog.value) + PasswordDialog( + ctx = ctx, + showPasswordDialog = showPasswordDialog, + password = password, + keyboardController = keyboardController, + title = passwordTitle.value, + okAction = { + if (password.value != "") { + if (passwordTitle.value == ctx.getString(R.string.encrypt_password)) + backup(ctx, password.value) + else + restore(ctx, password.value, onRestartClick) + password.value = "" + } + }, + cancelAction = { + if (downloadsOutputUri != null) { + Utils.deleteFile(ctx, downloadsOutputUri!!) + } + } + ) + + if (showPasswordsDialog.value) { + if (passwordAccounts.isNotEmpty()) { + val account = passwordAccounts.removeAt(0) + val params = account.substringAfter(">") + if (Utils.paramValue(params, "auth_user") != "" && + Utils.paramValue(params, "auth_pass") == "") { + val aor = account.substringAfter("<").substringBefore(">") + PasswordDialog( + ctx = ctx, + showPasswordDialog = showPasswordsDialog, + password = password, + keyboardController = keyboardController, + title = stringResource(R.string.authentication_password), + message = stringResource(R.string.account) + " " + Utils.plainAor(aor), + okAction = { + if (password.value != "") + BaresipService.aorPasswords[aor] = password.value + showPasswordsDialog.value = true + }, + cancelAction = { + showPasswordsDialog.value = true + } + ) + } else { + showPasswordsDialog.value = false + showPasswordsDialog.value = true + } + } + else + onRequestPermissions() + } + + LaunchedEffect(Unit) { + if (!BaresipService.isServiceRunning) { + val path = ctx.filesDir.absolutePath + "/accounts" + if (File(path).exists()) { + passwordAccounts = String( + Utils.getFileContents(path)!!, + Charsets.UTF_8 + ).lines().toMutableList() + showPasswordsDialog.value = true + } else { + // Baresip is started for the first time + onRequestPermissions() + } + } + } + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + LaunchedEffect(currentRoute, viewModel.selectedAor.collectAsState()) { + if (currentRoute == "main") { + Log.d(TAG, "Updating icons for AOR: ${viewModel.selectedAor.value}") + val account = Account.ofAor(viewModel.selectedAor.value) + updateIcons(viewModel, account) + } + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + viewModel = viewModel, + navController = navController, + onBackupClick = { launchBackupRequest() }, + onRestoreClick = { launchRestoreRequest() }, + onLogcatClick = { launchLogcatRequest() }, + onRestartClick = onRestartClick, + onQuitClick = onQuitClick + ) + } + }, + bottomBar = { BottomBar(ctx, viewModel, navController) }, + content = { contentPadding -> + MainContent(navController, viewModel, contentPadding) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + viewModel: ViewModel, + navController: NavController, + onBackupClick: () -> Unit, + onRestoreClick: () -> Unit, + onLogcatClick: () -> Unit, + onRestartClick: () -> Unit, + onQuitClick: () -> Unit +) { + val ctx = LocalContext.current + val currentSpeakerIcon by viewModel.speakerIcon.collectAsState() + val currentMicIcon by viewModel.micIcon.collectAsState() + + val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + val recOffImage = ImageVector.vectorResource(R.drawable.rec_off) + val recOnImage = ImageVector.vectorResource(R.drawable.rec_on) + var recImage by remember { mutableStateOf(recOffImage) } + + var menuExpanded by remember { mutableStateOf(false) } + + val about = stringResource(R.string.about) + val settings = stringResource(R.string.configuration) + val accounts = stringResource(R.string.accounts) + val backup = stringResource(R.string.backup) + val restore = stringResource(R.string.restore) + val logcat = stringResource(R.string.logcat) + val restart = stringResource(R.string.restart) + val quit = stringResource(R.string.quit) + + TopAppBar( + title = { + Text( + text = stringResource(R.string.baresip), + color = LocalCustomColors.current.light, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ), + windowInsets = WindowInsets(0, 0, 0, 0), + actions = { + + Icon( + imageVector = recImage, + modifier = Modifier + .size(40.dp) + .combinedClickable( + onClick = { + if (Call.call("connected") == null) { + BaresipService.isRecOn = !BaresipService.isRecOn + recImage = if (BaresipService.isRecOn) { + Api.module_load("sndfile") + recOnImage + } else { + Api.module_unload("sndfile") + recOffImage + } + } else + Toast.makeText(ctx, R.string.rec_in_call, Toast.LENGTH_SHORT) + .show() + }, + onLongClick = { + alertTitle.value = ctx.getString(R.string.call_recording_title) + alertMessage.value = ctx.getString(R.string.call_recording_tip) + showAlert.value = true + } + ), + tint = Color.Unspecified, + contentDescription = null + ) + + Spacer(modifier = Modifier.width(22.dp)) + + Icon( + imageVector = ImageVector.vectorResource(currentMicIcon), + modifier = Modifier + .size(40.dp) + .combinedClickable( + onClick = { + if (Call.call("connected") != null) { + BaresipService.isMicMuted = !BaresipService.isMicMuted + if (BaresipService.isMicMuted) { + viewModel.updateMicIcon(R.drawable.mic_off) + Api.calls_mute(true) + } else { + viewModel.updateMicIcon(R.drawable.mic_on) + Api.calls_mute(false) + } + } + }, + onLongClick = { + alertTitle.value = ctx.getString(R.string.microphone_title) + alertMessage.value = ctx.getString(R.string.microphone_tip) + showAlert.value = true + }, + ), + tint = Color.Unspecified, + contentDescription = null + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Icon( + imageVector = ImageVector.vectorResource(currentSpeakerIcon), + modifier = Modifier + .size(40.dp) + .combinedClickable( + onClick = { + if (Build.VERSION.SDK_INT >= 31) + Log.d( + TAG, "Toggling speakerphone when dev/mode is " + + "${am.communicationDevice!!.type}/${am.mode}" + ) + Utils.toggleSpeakerPhone(ContextCompat.getMainExecutor(ctx), am) + viewModel.updateSpeakerIcon( + if (Utils.isSpeakerPhoneOn(am)) + R.drawable.speaker_on + else + R.drawable.speaker_off + ) + }, + onLongClick = { + alertTitle.value = ctx.getString(R.string.speakerphone_title) + alertMessage.value = ctx.getString(R.string.speakerphone_tip) + showAlert.value = true + }, + ), + tint = Color.Unspecified, + contentDescription = null + ) + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { menuExpanded = !menuExpanded } + ) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Menu", + tint = LocalCustomColors.current.light + ) + } + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + items = if (Build.VERSION.SDK_INT >= 29) + listOf(about, settings, accounts, backup, restore, logcat, restart, quit) + else + listOf(about, settings, accounts, backup, restore, restart, quit), + onItemClick = { selectedItem -> + menuExpanded = false + when (selectedItem) { + about -> { navController.navigate("about") } + settings -> { navController.navigate("settings") } + accounts -> { navController.navigate("accounts") } + backup -> onBackupClick() + restore -> onRestoreClick() + logcat -> onLogcatClick() + restart -> onRestartClick() + quit -> onQuitClick() + } + } + ) + } + ) +} + +@Composable +private fun BottomBar(ctx: Context, viewModel: ViewModel, navController: NavController) { + + val vmIcon by viewModel.vmIcon.collectAsState() + val showVmIcon by viewModel.showVmIcon.collectAsState() + val messagesIcon by viewModel.messagesIcon.collectAsState() + val callsIcon by viewModel.callsIcon.collectAsState() + val dialpadIcon by viewModel.dialpadIcon.collectAsState() + + val buttonSize = 48.dp + + Row( modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + if (showVmIcon) + IconButton( + onClick = { + if (viewModel.selectedAor.value != "") { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val acc = ua.account + if (acc.vmUri != "") { + dialogTitle.value = ctx.getString(R.string.voicemail_messages) + dialogMessage.value = acc.vmMessages(ctx) + positiveText.value = ctx.getString(R.string.listen) + onPositiveClicked.value = { + val intent = Intent(ctx, MainActivity::class.java) + intent.putExtra("uap", ua.uap) + intent.putExtra("peer", acc.vmUri) + handleIntent(ctx, viewModel, intent, "call") + } + negativeText.value = ctx.getString(R.string.cancel) + onNegativeClicked.value = {} + showDialog.value = true + } + } + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(vmIcon), + contentDescription = null, + Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + + IconButton( + onClick = { navController.navigate("contacts") }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.contacts), + contentDescription = null, + Modifier.size(buttonSize), + tint = LocalCustomColors.current.secondary + ) + } + + IconButton( + onClick = { + if (viewModel.selectedAor.value != "") + navController.navigate("chats/${viewModel.selectedAor.value}") + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(messagesIcon), + contentDescription = null, + Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + + IconButton( + onClick = { + if (viewModel.selectedAor.value != "") + navController.navigate("calls/${viewModel.selectedAor.value}") + }, + modifier = Modifier + .weight(1f) + .size(buttonSize) + ) { + Icon( + imageVector = ImageVector.vectorResource(callsIcon), + contentDescription = null, + Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + + IconButton( + onClick = { viewModel.updateDialpadIcon( + if (dialpadIcon == R.drawable.dialpad_off) + R.drawable.dialpad_on + else + R.drawable.dialpad_off + ) }, + modifier = Modifier + .weight(1f) + .size(buttonSize), + enabled = dialpadButtonEnabled + ) { + Icon( + imageVector = ImageVector.vectorResource(dialpadIcon), + contentDescription = null, + modifier = Modifier.size(buttonSize), + tint = Color.Unspecified + ) + } + } + +} + +private val callUri = mutableStateOf("") +private var callUriEnabled = mutableStateOf(true) +private val callUriLabel = mutableStateOf("") +private var securityIcon = mutableIntStateOf(-1) +private val showCallTimer = mutableStateOf(false) +private var callDuration = 0 +private val showSuggestions = mutableStateOf(false) +private val showCallButton = mutableStateOf(true) +private val showCancelButton = mutableStateOf(false) +private val showAnswerRejectButtons = mutableStateOf(false) +private val showHangupButton = mutableStateOf(false) +private val showOnHoldNotice = mutableStateOf(false) +private var holdIcon = mutableIntStateOf(R.drawable.call_hold) +private var transferButtonEnabled = mutableStateOf(false) +private val transferIcon = mutableIntStateOf(R.drawable.call_transfer) +private var dtmfText = mutableStateOf("") +private val dtmfEnabled = mutableStateOf(false) +private var focusDtmf = mutableStateOf(false) + +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + +private val dialogTitle = mutableStateOf("") +private val dialogMessage = mutableStateOf("") +private val positiveText = mutableStateOf("") +private val onPositiveClicked = mutableStateOf({}) +private val negativeText = mutableStateOf("") +private val onNegativeClicked = mutableStateOf({}) +private val showDialog = mutableStateOf(false) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainContent(navController: NavController, viewModel: ViewModel, contentPadding: PaddingValues) { + + var isRefreshing by remember { mutableStateOf(false) } + val refreshState = rememberPullToRefreshState() + var offset by remember { mutableFloatStateOf(0f) } + val swipeThreshold = 200 + val ctx = LocalContext.current + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(1000) + isRefreshing = false + } + } + + if (showAlert.value) + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + + if (showDialog.value) + AlertDialog( + showDialog = showDialog, + title = stringResource(R.string.confirmation), + message = dialogMessage.value, + positiveButtonText = positiveText.value, + onPositiveClicked = onPositiveClicked.value, + negativeButtonText = negativeText.value, + onNegativeClicked = onNegativeClicked.value + ) + + SelectableAlertDialog( + openDialog = showSelectItemDialog, + title = stringResource(R.string.choose_destination_uri), + items = selectItems.value, + onItemClicked = selectItemAction.value, + neutralButtonText = stringResource(R.string.cancel), + onNeutralClicked = {} + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(top = 18.dp, bottom = 6.dp, start = 16.dp, end = 16.dp) + .fillMaxSize() + .pullToRefresh( + state = refreshState, + isRefreshing = isRefreshing, + onRefresh = { + isRefreshing = true + if (uas.value.isNotEmpty()) { + if (viewModel.selectedAor.value == "") + spinToAor(viewModel, uas.value.first().account.aor) + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + if (ua.account.regint > 0) + Api.ua_register(ua.uap) + } + }, + enabled = pullToRefreshEnabled, + ) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset = 0f }, + onDragEnd = { + if (offset < -swipeThreshold) { + if (uas.value.isNotEmpty()) { + val curPos = UserAgent.findAorIndex(viewModel.selectedAor.value) + val newPos = if (curPos == null) + 0 + else + (curPos + 1) % uas.value.size + if (curPos != newPos) { + val ua = uas.value[newPos] + spinToAor(viewModel, ua.account.aor) + showCall(ctx, viewModel, ua) + } + } + } else if (offset > swipeThreshold) { + if (uas.value.isNotEmpty()) { + val curPos = UserAgent.findAorIndex(viewModel.selectedAor.value) + val newPos = when (curPos) { + null -> 0 + 0 -> uas.value.size - 1 + else -> curPos - 1 + } + if (curPos != newPos) { + val ua = uas.value[newPos] + spinToAor(viewModel, ua.account.aor) + showCall(ctx, viewModel, ua) + } + } + } + } + ) { _, dragAmount -> + offset += dragAmount + } + } + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AccountSpinner(ctx, viewModel, navController) + CallUriRow(ctx, viewModel) + CallRow(ctx, viewModel) + if (showOnHoldNotice.value) + OnHoldNotice() + Indicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + isRefreshing = isRefreshing, + state = refreshState, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AccountSpinner(ctx: Context, viewModel: ViewModel, navController: NavController) { + + var expanded by rememberSaveable { mutableStateOf(false) } + val selected: String by viewModel.selectedAor.collectAsState() + + if (uas.value.isEmpty()) + viewModel.updateSelectedAor("") + else + if (selected == "" || UserAgent.ofAor(selected) == null) { + viewModel.updateSelectedAor(uas.value.first().account.aor) + } + + showCall(ctx, viewModel, UserAgent.ofAor(selected)) + + updateIcons(viewModel, Account.ofAor(selected)) + + if (selected == "") { + OutlinedButton( + onClick = { + navController.navigate("accounts") + }, + modifier = Modifier + .padding(horizontal = 4.dp) + .height(50.dp) + .fillMaxWidth(), + colors = ButtonColors( + containerColor = LocalCustomColors.current.grayLight, + contentColor = LocalCustomColors.current.dark, + disabledContainerColor = LocalCustomColors.current.grayLight, + disabledContentColor = LocalCustomColors.current.dark + ), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Text(text = "") + } + } + else + OutlinedButton( + onClick = { + expanded = !expanded + }, + enabled = true, + modifier = Modifier + .padding(horizontal = 4.dp) + .height(50.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + expanded = true + }, + onLongPress = { + val ua = UserAgent.ofAor(selected) + if (ua != null) { + val acc = ua.account + if (Api.account_regint(acc.accp) > 0) { + Api.account_set_regint(acc.accp, 0) + Api.ua_unregister(ua.uap) + } else { + Api.account_set_regint( + acc.accp, + acc.configuredRegInt + ) + Api.ua_register(ua.uap) + } + acc.regint = Api.account_regint(acc.accp) + Account.saveAccounts() + } + } + ) + }, + colors = ButtonColors( + containerColor = LocalCustomColors.current.grayLight, + contentColor = LocalCustomColors.current.dark, + disabledContainerColor = LocalCustomColors.current.grayLight, + disabledContentColor = LocalCustomColors.current.dark + ), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource( + uasStatus.value[selected] ?: + R.drawable.locked_yellow), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .padding(end = 10.dp) + .clickable(onClick = { + navController.navigate("account/$selected/old") + }) + ) + Text( + text = Account.ofAor(selected)?.text() ?: "", + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier + .weight(1f) + .combinedClickable( + onClick = { expanded = true }, + onLongClick = { + val ua = UserAgent.ofAor(selected) + if (ua != null) { + val acc = ua.account + if (Api.account_regint(acc.accp) > 0) { + Api.account_set_regint(acc.accp, 0) + Api.ua_unregister(ua.uap) + } else { + Api.account_set_regint( + acc.accp, + acc.configuredRegInt + ) + Api.ua_register(ua.uap) + } + acc.regint = Api.account_regint(acc.accp) + Account.saveAccounts() + } + } + ) + ) + Icon( + imageVector = if (expanded) + Icons.Default.KeyboardArrowUp + else + Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + uas.value.forEachIndexed { _, ua -> + val acc = ua.account + DropdownMenuItem( + onClick = { + expanded = false + viewModel.updateSelectedAor(acc.aor) + showCall(ctx, viewModel, ua) + updateIcons(viewModel, acc) + }, + text = { Text( + text = acc.text(), + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) }, + leadingIcon = { + Icon( + imageVector = ImageVector.vectorResource(uasStatus.value[acc.aor]!!), + contentDescription = null, + tint = Color.Unspecified, + ) + } + ) + } + } + } +} + +@Composable +private fun CallUriRow(ctx: Context, viewModel: ViewModel) { + + val suggestions by remember { contactNames } + var filteredSuggestions by remember { mutableStateOf(suggestions) } + val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() + val dialpadIcon by viewModel.dialpadIcon.collectAsState() + + Row(modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = callUri.value, + enabled = callUriEnabled.value, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + disabledBorderColor = OutlinedTextFieldDefaults.colors().unfocusedIndicatorColor), + onValueChange = { + if (it != callUri.value) { + callUri.value = it + filteredSuggestions = suggestions.filter { suggestion -> + it.length > 2 && suggestion.startsWith(it, ignoreCase = true) + } + showSuggestions.value = it.length > 2 + } + }, + trailingIcon = { + if (callUriEnabled.value && callUri.value.isNotEmpty()) + Icon(Icons.Outlined.Clear, + contentDescription = null, + modifier = Modifier + .clickable { + if (showSuggestions.value) + showSuggestions.value = false + else + callUri.value = "" + } + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 4.dp, top = 12.dp, bottom = 2.dp) + .focusRequester(focusRequester) + .onFocusChanged { + val account = Account.ofAor(viewModel.selectedAor.value) + if (account != null) + if (account.numericKeypad) + viewModel.updateDialpadIcon(R.drawable.dialpad_on) + }, + label = { + LabelText( + text = callUriLabel.value, + fontSize = 18.sp, + ) + }, + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + keyboardOptions = if (dialpadIcon == R.drawable.dialpad_on) + KeyboardOptions(keyboardType = KeyboardType.Phone) + else + KeyboardOptions(keyboardType = KeyboardType.Text) + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(8.dp, RoundedCornerShape(8.dp)) + .background( + LocalCustomColors.current.grayLight, + shape = RoundedCornerShape(8.dp) + ) + .animateContentSize() + ) { + if (showSuggestions.value && filteredSuggestions.isNotEmpty()) { + Box(modifier = Modifier + .fillMaxWidth() + .heightIn(max = 150.dp)) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .verticalScrollbar( + state = lazyListState, + color = LocalCustomColors.current.gray + ), + horizontalAlignment = Alignment.Start, + state = lazyListState + ) { + items( + items = filteredSuggestions, + key = { suggestion -> suggestion } + ) { suggestion -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + callUri.value = suggestion + showSuggestions.value = false + } + .padding(12.dp) + ) { + Text( + text = suggestion, + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColors.current.grayDark, + fontSize = 18.sp + ) + } + } + } + } + } + } + } + if (showCallTimer.value) { + val textColor = LocalCustomColors.current.itemText.toArgb() + AndroidView( + factory = { context -> + val chronometer = Chronometer(context).apply { + textSize = 16F + setTextColor(textColor) + base = SystemClock.elapsedRealtime() - (callDuration * 1000L) + start() + } + chronometer + }, + modifier = Modifier.padding(start = 6.dp, + top = 4.dp, + end = if (securityIcon.intValue != -1) 6.dp else 0.dp), + ) + } + if (securityIcon.intValue != -1) { + Icon( + imageVector = ImageVector.vectorResource(securityIcon.intValue), + contentDescription = null, + modifier = Modifier + .size(28.dp) + .padding(top = 4.dp) + .clickable { + when (securityIcon.intValue) { + R.drawable.unlocked -> { + alertTitle.value = ctx.getString(R.string.alert) + alertMessage.value = ctx.getString(R.string.call_not_secure) + showAlert.value = true + } + + R.drawable.locked_yellow -> { + alertTitle.value = ctx.getString(R.string.alert) + alertMessage.value = ctx.getString(R.string.peer_not_verified) + showAlert.value = true + } + + R.drawable.locked_green -> { + dialogTitle.value = ctx.getString(R.string.info) + dialogMessage.value = ctx.getString(R.string.call_is_secure) + positiveText.value = ctx.getString(R.string.unverify) + onPositiveClicked.value = { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + if (Api.cmd_exec("zrtp_unverify " + call.zid) != 0) + Log.e( + TAG, + "Command 'zrtp_unverify ${call.zid}' failed" + ) + else + securityIcon.intValue = R.drawable.locked_yellow + } + } + negativeText.value = ctx.getString(R.string.cancel) + showDialog.value = true + } + } + }, + tint = Color.Unspecified, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CallRow(ctx: Context, viewModel: ViewModel) { + + val dialpadIcon by viewModel.dialpadIcon.collectAsState() + + Row( modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween + ) { + if (showCallButton.value) + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + showSuggestions.value = false + callClick(ctx, viewModel) + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.call), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + if (showCancelButton.value) { + Spacer(modifier = Modifier.weight(1f)) + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + showSuggestions.value = false + abandonAudioFocus(ctx) + var ua: UserAgent = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + val callp = call.callp + Log.d( + TAG, + "AoR ${ua.account.aor} hanging up call $callp with ${callUri.value}" + ) + Api.ua_hangup(ua.uap, callp, 0, "") + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.hangup), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + } + + if (showHangupButton.value) { + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + abandonAudioFocus(ctx) + val uaCalls = ua.calls() + if (uaCalls.isNotEmpty()) { + val call = uaCalls.last() + val callp = call.callp + Log.d(TAG, "AoR ${ua.account.aor} hanging up call $callp with ${callUri.value}") + Api.ua_hangup(ua.uap, callp, 0, "") + } + } + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.hangup), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val aor = ua.account.aor + val call = ua.currentCall() + if (call != null) { + if (call.onhold) { + Log.d( + TAG, + "AoR $aor resuming call ${call.callp} with ${callUri.value}" + ) + call.resume() + call.onhold = false + holdIcon.intValue = R.drawable.call_hold + } else { + Log.d( + TAG, + "AoR $aor holding call ${call.callp} with ${callUri.value}" + ) + call.hold() + call.onhold = true + holdIcon.intValue = R.drawable.resume + } + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = holdIcon.intValue), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + var showTransferDialog by remember { mutableStateOf(false) } + IconButton( + modifier = Modifier.size(48.dp), + enabled = transferButtonEnabled.value, + onClick = { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + if (call.onHoldCall != null) { + if (!call.executeTransfer()) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.transfer_failed) + showAlert.value = true + } + } else + showTransferDialog = true + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(transferIcon.intValue), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + if (showTransferDialog) { + + val showDialog = remember { mutableStateOf(true) } + val blindChecked = remember { mutableStateOf(true) } + val selectedAor: String by viewModel.selectedAor.collectAsState() + val ua = UserAgent.ofAor(selectedAor)!! + val call = ua.currentCall() + + if (showDialog.value) + BasicAlertDialog( + onDismissRequest = { + keyboardController?.hide() + showDialog.value = false + showTransferDialog = false + } + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 0.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = LocalCustomColors.current.cardBackground + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.call_transfer), + fontSize = 20.sp, + color = LocalCustomColors.current.alert, + ) + var transferUri by remember { mutableStateOf("") } + val suggestions by remember { contactNames } + var filteredSuggestions by remember { mutableStateOf(suggestions) } + val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() + OutlinedTextField( + value = transferUri, + singleLine = true, + onValueChange = { + if (it != transferUri) { + transferUri = it + filteredSuggestions = + suggestions.filter { suggestion -> + transferUri.length > 2 && + suggestion.startsWith( + transferUri, + ignoreCase = true + ) + } + showSuggestions.value = transferUri.length > 2 + } + }, + trailingIcon = { + if (transferUri.isNotEmpty()) + Icon( + Icons.Outlined.Clear, + contentDescription = null, + modifier = Modifier.clickable { + if (showSuggestions.value) + showSuggestions.value = false + else + transferUri = "" + } + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 4.dp, + end = 4.dp, + top = 12.dp, + bottom = 2.dp + ) + .focusRequester(focusRequester), + label = { LabelText(stringResource(R.string.transfer_destination)) }, + textStyle = TextStyle( + fontSize = 18.sp, + color = LocalCustomColors.current.itemText + ), + keyboardOptions = if (dialpadIcon == R.drawable.dialpad_on) + KeyboardOptions(keyboardType = KeyboardType.Phone) + else + KeyboardOptions(keyboardType = KeyboardType.Text) + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(8.dp, RoundedCornerShape(8.dp)) + .background( + LocalCustomColors.current.grayLight, + shape = RoundedCornerShape(8.dp) + ) + .animateContentSize() + ) { + if (showSuggestions.value && filteredSuggestions.isNotEmpty()) { + Box(modifier = Modifier + .fillMaxWidth() + .heightIn(max = 150.dp)) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .verticalScrollbar( + state = lazyListState, + color = LocalCustomColors.current.gray + ), + horizontalAlignment = Alignment.Start, + state = lazyListState, + ) { + items( + items = filteredSuggestions, + key = { suggestion -> suggestion } + ) { suggestion -> + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + transferUri = suggestion + showSuggestions.value = false + } + .padding(12.dp) + ) { + Text( + text = suggestion, + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColors.current.grayDark, + fontSize = 18.sp + ) + } + } + } + } + } + } + if (call != null && call.replaces()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.blind), + color = LocalCustomColors.current.alert, + modifier = Modifier.padding(8.dp), + ) + Switch( + checked = blindChecked.value, + onCheckedChange = { + blindChecked.value = true + } + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.attended), + color = LocalCustomColors.current.alert, + modifier = Modifier.padding(8.dp), + ) + Switch( + checked = !blindChecked.value, + onCheckedChange = { + blindChecked.value = false + } + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { + keyboardController?.hide() + showDialog.value = false + showTransferDialog = false + }, + modifier = Modifier.padding(end = 32.dp), + ) { + Text( + text = stringResource(R.string.cancel), + color = LocalCustomColors.current.gray + ) + } + TextButton( + onClick = { + showSuggestions.value = false + var uriText = transferUri.trim() + if (uriText.isNotEmpty()) { + val uris = Contact.contactUris(uriText) + if (uris.size > 1) { + selectItems.value = uris + selectItemAction.value = { index -> + val uri = uris[index] + transfer( + ctx, + viewModel, + ua, + if (Utils.isTelNumber(uri)) "tel:$uri" else uri, + !blindChecked.value + ) + showSelectItemDialog.value = false + } + showSelectItemDialog.value = true + } + else { + if (uris.size == 1) uriText = uris[0] + transfer( + ctx, + viewModel, + ua, + if (Utils.isTelNumber(uriText)) "tel:$uriText" else uriText, + !blindChecked.value + ) + } + keyboardController?.hide() + showDialog.value = false + showTransferDialog = false + } + }, + modifier = Modifier.padding(end = 16.dp), + ) { + Text( + text = stringResource( + if (blindChecked.value) + R.string.transfer + else + R.string.call + ).uppercase(), + color = LocalCustomColors.current.primary + ) + } + } + } + } + } + } + + val focusRequester = remember { FocusRequester() } + val shouldRequestFocus by focusDtmf + TextField( + value = dtmfText.value, + onValueChange = { + if (it.length > dtmfText.value.length) { + val char = it.last() + if (char.isDigit() || char == '*' || char == '#') { + Log.d(TAG, "Got DTMF digit '$char'") + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + ua.currentCall()?.sendDigit(char) + } + } + dtmfText.value = it + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .width(80.dp) + .focusRequester(focusRequester), + enabled = dtmfEnabled.value, + textStyle = TextStyle(fontSize = 16.sp), + label = { LabelText(stringResource(R.string.dtmf)) }, + singleLine = true + ) + LaunchedEffect(shouldRequestFocus) { + if (shouldRequestFocus) { + focusRequester.requestFocus() + focusDtmf.value = false + } + } + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + val stats = call?.stats("audio") + if (stats != null && call.startTime != null && stats != "") { + val parts = stats.split(",") as java.util.ArrayList + if (parts[2] == "0/0") { + parts[2] = "?/?" + parts[3] = "?/?" + parts[4] = "?/?" + } + val codecs = call.audioCodecs() + val duration = call.duration() + val txCodec = codecs.split(',')[0].split("/") + val rxCodec = codecs.split(',')[1].split("/") + alertTitle.value = ctx.getString(R.string.call_info) + alertMessage.value = + "${String.format(ctx.getString(R.string.duration), duration)}\n" + + "${ctx.getString(R.string.codecs)}: ${txCodec[0]} ch ${txCodec[2]}/" + + "${rxCodec[0]} ch ${rxCodec[2]}\n" + + "${String.format(ctx.getString(R.string.rate), parts[0])}\n" + + "${String.format(ctx.getString(R.string.average_rate), parts[1])}\n" + + "${ctx.getString(R.string.packets)}: ${parts[2]}\n" + + "${ctx.getString(R.string.lost)}: ${parts[3]}\n" + + String.format(ctx.getString(R.string.jitter), parts[4]) + showAlert.value = true + } else { + alertTitle.value = ctx.getString(R.string.call_info) + alertMessage.value = ctx.getString(R.string.call_info_not_available) + showAlert.value = true + } + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.info), + modifier = Modifier.size(36.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + } + + if (showAnswerRejectButtons.value) { + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + answer(ctx, viewModel) + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.call), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + + Spacer(Modifier.weight(1f)) + + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + reject(viewModel) + }, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.hangup), + modifier = Modifier.size(48.dp), + tint = Color.Unspecified, + contentDescription = null, + ) + } + } + } +} + +@Composable +private fun OnHoldNotice() { + OutlinedButton( + onClick = {}, + border = BorderStroke(1.dp, LocalCustomColors.current.accent), + modifier = Modifier.padding(16.dp), + shape = RoundedCornerShape(20) + ) { + Text( + text = stringResource(R.string.call_is_on_hold), + fontSize = 18.sp, + color = LocalCustomColors.current.itemText, + ) + } +} + +private fun spinToAor(viewModel: ViewModel, aor: String) { + if (aor != viewModel.selectedAor.value) + viewModel.updateSelectedAor(aor) + updateIcons(viewModel, Account.ofAor(aor)) +} + +private fun callClick(ctx: Context, viewModel: ViewModel) { + if (viewModel.selectedAor.value != "") { + if (Utils.checkPermissions(ctx, arrayOf(RECORD_AUDIO))) { + if (Call.inCall()) + return + val uriText = callUri.value.trim() + if (uriText.isNotEmpty()) { + val uris = Contact.contactUris(uriText) + if (uris.isEmpty()) + makeCall(ctx, viewModel, uriText) + else if (uris.size == 1) + makeCall(ctx, viewModel, uris[0]) + else { + selectItems.value = uris + selectItemAction.value = { index -> + makeCall(ctx, viewModel, uris[index]) + } + showSelectItemDialog.value = true + } + } + else { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val latestPeerUri = CallHistoryNew.aorLatestPeerUri(ua.account.aor) + if (latestPeerUri != null) + callUri.value = Utils.friendlyUri(ctx, latestPeerUri, ua.account) + } + } + else + Toast.makeText(ctx, R.string.no_calls, Toast.LENGTH_SHORT).show() + } +} + +private fun makeCall(ctx: Context, viewModel: ViewModel, uriText: String) { + val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val aor = ua.account.aor + val peerUri = if (Utils.isTelNumber(uriText)) + "tel:$uriText" + else + uriText + val uri = if (Utils.isTelUri(peerUri)) { + if (ua.account.telProvider == "") { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.no_telephony_provider), aor) + showAlert.value = true + return + } + Utils.telToSip(peerUri, ua.account) + } + else + Utils.uriComplete(peerUri, aor) + if (!Utils.checkUri(uri)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri) + showAlert.value = true + } + else if (!BaresipService.requestAudioFocus(ctx)) + Toast.makeText(ctx, R.string.audio_focus_denied, Toast.LENGTH_SHORT).show() + else { + if (Build.VERSION.SDK_INT < 31) { + Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") + am.mode = AudioManager.MODE_IN_COMMUNICATION + runCall(ctx, viewModel, ua, uri) + } else { + if (am.mode == AudioManager.MODE_IN_COMMUNICATION) { + runCall(ctx, viewModel, ua, uri) + } else { + audioModeChangedListener = AudioManager.OnModeChangedListener { mode -> + if (mode == AudioManager.MODE_IN_COMMUNICATION) { + Log.d(TAG, "Audio mode changed to MODE_IN_COMMUNICATION using " + + "device ${am.communicationDevice!!.type}") + if (audioModeChangedListener != null) { + am.removeOnModeChangedListener(audioModeChangedListener!!) + audioModeChangedListener = null + } + runCall(ctx, viewModel, ua, uri) + } else { + Log.d(TAG, "Audio mode changed to mode ${am.mode} using " + + "device ${am.communicationDevice!!.type}") + } + } + am.addOnModeChangedListener(ctx.mainExecutor, audioModeChangedListener!!) + Log.d(TAG, "Setting audio mode to MODE_IN_COMMUNICATION") + am.mode = AudioManager.MODE_IN_COMMUNICATION + } + } + } +} + +private fun runCall(ctx: Context, viewModel: ViewModel, ua: UserAgent, uri: String) { + callRunnable = Runnable { + callRunnable = null + if (!call(ctx, viewModel, ua, uri)) { + BaresipService.abandonAudioFocus(ctx) + showCallButton.value = true + showCancelButton.value = false + } + else { + showCallButton.value = false + showCancelButton.value = true + } + } + callHandler.postDelayed(callRunnable!!, BaresipService.audioDelay) +} + +private fun call( + ctx: Context, + viewModel: ViewModel, + ua: UserAgent, + uri: String, + onHoldCall: Call? = null +): Boolean { + spinToAor(viewModel, ua.account.aor) + val callp = ua.callAlloc(0L, Api.VIDMODE_OFF) + return if (callp != 0L) { + Log.d(TAG, "Adding outgoing call ${ua.uap}/$callp/$uri") + val call = Call(callp, ua, uri, "out", "outgoing") + call.onHoldCall = onHoldCall + call.add() + if (onHoldCall != null) + onHoldCall.newCall = call + if (call.connect(uri)) { + showCall(ctx, viewModel, ua) + true + } else { + Log.w(TAG, "call_connect $callp failed") + if (onHoldCall != null) + onHoldCall.newCall = null + call.remove() + call.destroy() + showCall(ctx, viewModel, ua) + false + } + } else { + Log.w(TAG, "callAlloc for ${ua.uap} to $uri failed") + false + } +} + +private fun answer(ctx: Context, viewModel: ViewModel) { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + Log.d(TAG, "AoR ${ua.account.aor} answering call from ${callUri.value}") + val intent = Intent(ctx, BaresipService::class.java) + intent.action = "Call Answer" + intent.putExtra("uap", ua.uap) + intent.putExtra("callp", call.callp) + intent.putExtra("video", Api.VIDMODE_OFF) + ctx.startService(intent) + } +} + +private fun reject(viewModel: ViewModel) { + val ua = UserAgent.ofAor(viewModel.selectedAor.value)!! + val call = ua.currentCall() + if (call != null) { + val callp = call.callp + Log.d(TAG, "AoR ${ua.account.aor} rejecting call $callp from ${callUri.value}") + call.rejected = true + Api.ua_hangup(ua.uap, callp, 486, "Busy Here") + } +} + +private fun transfer(ctx: Context, viewModel: ViewModel, ua: UserAgent, uriText: String, attended: Boolean) { + val uri = if (Utils.isTelUri(uriText)) + Utils.telToSip(uriText, ua.account) + else + Utils.uriComplete(uriText, ua.account.aor) + if (!Utils.checkUri(uri)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), uri) + showAlert.value = true + } + else { + val call = ua.currentCall() + if (call != null) { + if (attended) { + if (call.hold()) { + call.referTo = uri + call(ctx, viewModel, ua, uri, call) + } + } + else { + if (!call.transfer(uri)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = ctx.getString(R.string.transfer_failed) + showAlert.value = true + } + } + showCall(ctx, viewModel, ua) + } + } +} + +private fun showCall(ctx: Context, viewModel: ViewModel, ua: UserAgent?, showCall: Call? = null) { + if (ua == null) + return + val call = showCall ?: ua.currentCall() + if (call == null) { + pullToRefreshEnabled = true + if (ua.account.resumeUri != "") + callUri.value = ua.account.resumeUri + else + callUri.value = "" + callUriLabel.value = ctx.getString(R.string.outgoing_call_to_dots) + callUriEnabled.value = true + keyboardController?.hide() + showCallTimer.value = false + securityIcon.intValue = -1 + showHangupButton.value = false + transferIcon.intValue = R.drawable.call_transfer + dtmfEnabled.value = false + focusDtmf.value = false + showCallButton.value = true + showCancelButton.value = false + showAnswerRejectButtons.value = false + showOnHoldNotice.value = false + dialpadButtonEnabled = true + if (BaresipService.isMicMuted) { + BaresipService.isMicMuted = false + viewModel.updateMicIcon(R.drawable.mic_on) + } + } else { + pullToRefreshEnabled = false + callUriEnabled.value = false + when (call.status) { + "outgoing", "transferring", "answered" -> { + callUriLabel.value = if (call.status == "answered") + ctx.getString(R.string.incoming_call_from_dots) + else + ctx.getString(R.string.outgoing_call_to_dots) + callUri.value = Utils.friendlyUri(ctx, call.peerUri, ua.account) + showCallTimer.value = false + securityIcon.intValue = -1 + showCallButton.value = false + showCancelButton.value = call.status == "outgoing" + showHangupButton.value = !showCancelButton.value + showAnswerRejectButtons.value = false + showOnHoldNotice.value = false + dialpadButtonEnabled = false + } + "incoming" -> { + showCallTimer.value = false + securityIcon.intValue = -1 + val uri = call.diverterUri() + if (uri != "") { + callUriLabel.value = ctx.getString(R.string.diverted_by_dots) + callUri.value = Utils.friendlyUri(ctx, uri, ua.account) + } + else { + callUriLabel.value = ctx.getString(R.string.incoming_call_from_dots) + callUri.value = Utils.friendlyUri(ctx, call.peerUri, ua.account) + } + showCallButton.value = false + showCancelButton.value = false + showHangupButton.value = false + showAnswerRejectButtons.value = true + showOnHoldNotice.value = false + dialpadButtonEnabled = false + } + "connected" -> { + if (call.referTo != "") { + callUriLabel.value = ctx.getString(R.string.outgoing_call_to_dots) + callUri.value = Utils.friendlyUri(ctx, call.referTo, ua.account) + transferButtonEnabled.value = false + } else { + if (call.dir == "out") { + callUriLabel.value = ctx.getString(R.string.outgoing_call_to_dots) + callUri.value = Utils.friendlyUri(ctx, call.peerUri, ua.account) + } else { + callUriLabel.value = ctx.getString(R.string.incoming_call_from_dots) + callUri.value = Utils.friendlyUri(ctx, call.peerUri, ua.account) + } + transferButtonEnabled.value = true + } + transferIcon.intValue = if (call.onHoldCall == null) + R.drawable.call_transfer + else + R.drawable.call_transfer_execute + callDuration = call.duration() + showCallTimer.value = true + if (ua.account.mediaEnc == "") + securityIcon.intValue = -1 + else + securityIcon.intValue = call.security + showCallButton.value = false + showCancelButton.value = false + showHangupButton.value = true + showAnswerRejectButtons.value = false + if (call.onhold) + holdIcon.intValue = R.drawable.resume + else + holdIcon.intValue = R.drawable.call_hold + Handler(Looper.getMainLooper()).postDelayed({ + showOnHoldNotice.value = call.held + }, 100) + if (call.held) { + keyboardController?.hide() + dtmfEnabled.value = false + focusDtmf.value = false + } else { + dtmfEnabled.value = true + focusDtmf.value = true + if (ctx.resources.configuration.orientation == ORIENTATION_PORTRAIT) + keyboardController?.show() + } + } + } + } +} + +fun handleServiceEvent(ctx: Context, viewModel: ViewModel, event: String, params: ArrayList) { + + fun handleNextEvent(logMessage: String? = null) { + if (logMessage != null) + Log.w(TAG, logMessage) + if (BaresipService.serviceEvents.isNotEmpty()) { + val first = BaresipService.serviceEvents.removeAt(0) + handleServiceEvent(ctx, viewModel, first.event, first.params) + } + } + + if (event == "started") { + val uriString = params[0] as String + Log.d(TAG, "Handling service event 'started' with URI '$uriString'") + if (uriString != "") + callAction(ctx, viewModel, uriString.toUri(), "dial") + else { + if (viewModel.selectedAor.value == "" && uas.value.isNotEmpty()) + viewModel.updateSelectedAor(uas.value.first().account.aor) + } + if (Preferences(ctx).displayTheme != AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.setDefaultNightMode(Preferences(ctx).displayTheme) + } + handleNextEvent() + return + } + + val uap = params[0] as Long + val ua = UserAgent.ofUap(uap) + if (ua == null) { + handleNextEvent("handleServiceEvent '$event' did not find ua $uap") + return + } + + val ev = event.split(",") + Log.d(TAG, "Handling service event '${ev[0]}' for $uap") + val acc = ua.account + val aor = ua.account.aor + + when (ev[0]) { + "call rejected" -> { + if (aor == viewModel.selectedAor.value) + viewModel.updateCallsIcon(R.drawable.calls_missed) + } + "call incoming", "call outgoing" -> { + val callp = params[1] as Long + if (!BaresipService.isMainVisible) + viewModel.navigateToHome() + spinToAor(viewModel, aor) + showCall(ctx, viewModel, ua, Call.ofCallp(callp)) + } + "call answered" -> { + if (!BaresipService.isMainVisible) + viewModel.navigateToHome() + spinToAor(viewModel, aor) + showCall(ctx, viewModel, ua) + } + "call redirect" -> { + val redirectUri = ev[1] + val target = Utils.friendlyUri(ctx, redirectUri, acc) + if (acc.autoRedirect) { + redirect(ctx, viewModel, ua, target) + Toast.makeText(ctx, + String.format(ctx.getString(R.string.redirect_notice), target), + Toast.LENGTH_SHORT + ).show() + } + else { + dialogTitle.value = ctx.getString(R.string.redirect_request) + dialogMessage.value = String.format(ctx.getString(R.string.redirect_request_query), target) + positiveText.value = ctx.getString(R.string.yes) + onPositiveClicked.value = { + redirect(ctx, viewModel, ua, target) + } + negativeText.value = ctx.getString(R.string.no) + onNegativeClicked.value = {} + showDialog.value = true + } + showCall(ctx, viewModel, ua) + } + "call established" -> { + if (aor == viewModel.selectedAor.value) { + dtmfText.value = "" + showCall(ctx, viewModel, ua) + } + } + "call update" -> { + showCall(ctx, viewModel, ua) + } + "call verify" -> { + val callp = params[1] as Long + val call = Call.ofCallp(callp) + if (call == null) { + handleNextEvent("Call $callp to be verified is not found") + return + } + dialogTitle.value = ctx.getString(R.string.verify) + dialogMessage.value = String.format(ctx.getString(R.string.verify_sas), ev[1]) + positiveText.value = ctx.getString(R.string.yes) + onPositiveClicked.value = { + call.security = if (Api.cmd_exec("zrtp_verify ${ev[2]}") != 0) { + Log.e(TAG, "Command 'zrtp_verify ${ev[2]}' failed") + R.drawable.locked_yellow + } else { + R.drawable.locked_green + } + call.zid = ev[2] + if (aor == viewModel.selectedAor.value) + securityIcon.intValue = call.security + } + negativeText.value = ctx.getString(R.string.no) + onNegativeClicked.value = { + call.security = R.drawable.locked_yellow + call.zid = ev[2] + if (aor == viewModel.selectedAor.value) + securityIcon.intValue = R.drawable.locked_yellow + onNegativeClicked.value = {} + } + showDialog.value = true + } + "call verified", "call secure" -> { + val callp = params[1] as Long + val call = Call.ofCallp(callp) + if (call == null) { + handleNextEvent("Call $callp that is verified is not found") + return + } + if (aor == viewModel.selectedAor.value) + securityIcon.intValue = call.security + } + "call transfer", "transfer show" -> { + if (!BaresipService.isMainVisible) + viewModel.navigateToHome() + val callp = params[1] as Long + val call = Call.ofCallp(callp) + val target = Utils.friendlyUri(ctx, ev[1], acc) + dialogTitle.value = if (call != null) + ctx.getString(R.string.transfer_request) + else + ctx.getString(R.string.call_request) + dialogMessage.value = if (call != null) + String.format(ctx.getString(R.string.transfer_request_query), target) + else + String.format(ctx.getString(R.string.call_request_query), target) + positiveText.value = ctx.getString(R.string.yes) + onPositiveClicked.value = { + if (call in Call.calls()) + acceptTransfer(ctx, viewModel, ua, call!!, ev[1]) + else + makeCall(ctx, viewModel, ev[1]) + } + negativeText.value = ctx.getString(R.string.no) + onNegativeClicked.value = { + if (call in Call.calls()) + call!!.notifySipfrag(603, "Decline") + onNegativeClicked.value = {} + } + showDialog.value = true + } + "transfer accept" -> { + val callp = params[1] as Long + val call = Call.ofCallp(callp) + if (call in Call.calls()) + Api.ua_hangup(uap, callp, 0, "") + call(ctx, viewModel, ua, ev[1]) + showCall(ctx, viewModel, ua) + } + "transfer failed" -> { + showCall(ctx, viewModel, ua) + } + "call closed" -> { + val call = ua.currentCall() + if (call != null) { + call.resume() + callDuration = call.duration() + showCallTimer.value = true + } + else + showCallTimer.value = false + if (aor == viewModel.selectedAor.value) { + ua.account.resumeUri = "" + showCall(ctx, viewModel, ua) + if (acc.missedCalls) + viewModel.updateCallsIcon(R.drawable.calls_missed) + } + //if (kgm.isDeviceLocked) + // this.setShowWhenLocked(false) + } + "message", "message show", "message reply" -> { + Handler(Looper.getMainLooper()).postDelayed({ + viewModel.onNewMessageReceived(aor, params[1] as String) + }, 200) + } + "mwi notify" -> { + val lines = ev[1].split("\n") + for (line in lines) { + if (line.startsWith("Voice-Message:")) { + val counts = (line.split(" ")[1]).split("/") + acc.vmNew = counts[0].toInt() + acc.vmOld = counts[1].toInt() + break + } + } + if (aor == viewModel.selectedAor.value) { + viewModel.updateVmIcon(if (acc.vmNew > 0) + R.drawable.voicemail_new + else + R.drawable.voicemail + ) + } + } + else -> Log.e(TAG, "Unknown event '${ev[0]}'") + } + + handleNextEvent() +} + +fun handleIntent(ctx: Context, viewModel: ViewModel, intent: Intent, action: String) { + Log.d(TAG, "Handling intent '$action'") + val ev = action.split(",") + when (ev[0]) { + "call", "dial" -> { + if (Call.inCall()) { + Toast.makeText(ctx, ctx.getString(R.string.call_already_active), + Toast.LENGTH_SHORT).show() + return + } + val uap = intent.getLongExtra("uap", 0L) + val ua = UserAgent.ofUap(uap) + if (ua == null) { + Log.w(TAG, "handleIntent 'call' did not find ua $uap") + return + } + callUri.value = intent.getStringExtra("peer")!! + spinToAor(viewModel, ua.account.aor) + if (ev[0] == "call") + callClick(ctx, viewModel) + } + "call show", "call answer" -> { + val callp = intent.getLongExtra("callp", 0L) + val call = Call.ofCallp(callp) + if (call == null) { + Log.w(TAG, "handleIntent '$action' did not find call $callp") + return + } + val ua = call.ua + spinToAor(viewModel, ua.account.aor) + if (ev[0] == "call answer") { + answer(ctx, viewModel) + showCall(ctx, viewModel, ua, call) + } + else + BaresipService.postServiceEvent(ServiceEvent( + "call incoming", + arrayListOf(call.ua.uap, callp), + System.nanoTime()) + ) + } + "call missed" -> { + val uap = intent.getLongExtra("uap", 0L) + val ua = UserAgent.ofUap(uap) + if (ua == null) { + Log.w(TAG, "handleIntent did not find ua $uap") + return + } + spinToAor(viewModel, ua.account.aor) + viewModel.navigateToCalls(ua.account.aor) + } + "call transfer", "transfer show", "transfer accept" -> { + val callp = intent.getLongExtra("callp", 0L) + val call = Call.ofCallp(callp) + if (call == null) { + Log.w(TAG, "handleIntent '$action' did not find call $callp") + // moveTaskToBack(true) + return + } + val uri = if (ev[0] == "call transfer") + ev[1] + else + intent.getStringExtra("uri")!! + BaresipService.postServiceEvent(ServiceEvent( + ev[0] + "," + uri, + arrayListOf(call.ua.uap, callp), + System.nanoTime()) + ) + } + "message", "message show", "message reply" -> { + val uap = intent.getLongExtra("uap", 0L) + val ua = UserAgent.ofUap(uap) + if (ua == null) { + Log.w(TAG, "handleIntent did not find ua $uap") + return + } + spinToAor(viewModel, ua.account.aor) + BaresipService.postServiceEvent(ServiceEvent( + ev[0], + arrayListOf(uap, intent.getStringExtra("peer")!!), + System.nanoTime()) + ) + } + } +} + +fun handleDialog(ctx: Context, title: String, message: String, action: () -> Unit = {}) { + dialogTitle.value = title + dialogMessage.value = message + positiveText.value = ctx.getString(R.string.ok) + onPositiveClicked.value = { action() } + negativeText.value = "" + showDialog.value = true +} + +fun callAction(ctx: Context, viewModel: ViewModel, uri: Uri?, action: String) { + if (Call.inCall() || uas.value.isEmpty()) + return + Log.d(TAG, "Action $action to $uri") + if (uri != null) { + var uriStr: String + var uap = 0L + when (uri.scheme) { + "sip" -> { + uriStr = Utils.uriUnescape(uri.toString()) + var ua = UserAgent.ofDomain(Utils.uriHostPart(uriStr)) + if (ua == null && uas.value.isNotEmpty()) + ua = uas.value[0] + if (ua == null) { + Log.w(TAG, "No accounts for '$uriStr'") + return + } + uap = ua.uap + } + "tel" -> { + uriStr = uri.toString().replace("%2B", "+").replace("%20", "") + .filterNot { setOf('-', ' ', '(', ')').contains(it) } + var account: Account? = null + for (a in Account.accounts()) + if (a.telProvider != "") { + account = a + break + } + if (account == null) { + Log.w(TAG, "No telephony providers for '$uriStr'") + return + } + uap = UserAgent.ofAor(account.aor)!!.uap + } + else -> { + Log.w(TAG, "Unsupported URI scheme ${uri.scheme}") + return + } + } + val intent = Intent(ctx, MainActivity::class.java) + intent.putExtra("uap", uap) + intent.putExtra("peer", uriStr) + handleIntent(ctx, viewModel, intent, action) + } +} + +private fun redirect(ctx: Context, viewModel: ViewModel, ua: UserAgent, redirectUri: String) { + if (ua.account.aor != viewModel.selectedAor.value) + spinToAor(viewModel, ua.account.aor) + callUri.value = redirectUri + callClick(ctx, viewModel) +} + +private fun acceptTransfer(ctx: Context, viewModel: ViewModel, ua: UserAgent, call: Call, uri: String) { + val newCallp = ua.callAlloc(call.callp, Api.VIDMODE_OFF) + if (newCallp != 0L) { + Log.d(TAG, "Adding outgoing call ${ua.uap}/$newCallp/$uri") + val newCall = Call(newCallp, ua, uri, "out", "transferring") + newCall.add() + if (newCall.connect(uri)) { + if (ua.account.aor != viewModel.selectedAor.value) + spinToAor(viewModel, ua.account.aor) + showCall(ctx, viewModel, ua) + } else { + Log.w(TAG, "call_connect $newCallp failed") + call.notifySipfrag(500, "Call Error") + } + } else { + Log.w(TAG, "callAlloc for ua ${ua.uap} call ${call.callp} transfer failed") + call.notifySipfrag(500, "Call Error") + } +} + +private fun updateIcons(viewModel: ViewModel, acc: Account?) { + if (acc == null) { + viewModel.updateShowVmIcon(false) + viewModel.updateMessagesIcon(R.drawable.messages) + viewModel.updateCallsIcon(R.drawable.calls) + } + else { + + if (acc.vmUri != "") { + viewModel.updateShowVmIcon(true) + viewModel.updateVmIcon(if (acc.vmNew > 0) + R.drawable.voicemail_new + else + R.drawable.voicemail + ) + } else + viewModel.updateShowVmIcon(false) + + viewModel.updateMessagesIcon(if (acc.unreadMessages) + R.drawable.messages_unread + else + R.drawable.messages) + + viewModel.updateCallsIcon(if (acc.missedCalls) + R.drawable.calls_missed + else + R.drawable.calls) + } +} + +private fun backup(ctx: Context, password: String) { + val files = arrayListOf("accounts", "config", "contacts", "call_history", + "messages", "gzrtp.zid", "cert.pem", "ca_cert", "ca_certs.crt") + File(BaresipService.filesPath).walk().forEach { + if (it.name.endsWith(".png")) + files.add(it.name) + } + val zipFile = ctx.getString(R.string.app_name) + ".zip" + val zipFilePath = BaresipService.filesPath + "/$zipFile" + if (!Utils.zip(files, zipFile)) { + Log.w(TAG, "Failed to write zip file '$zipFile'") + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = String.format(ctx.getString(R.string.backup_failed), + Utils.fileNameOfUri(ctx, downloadsOutputUri!!)) + showAlert.value = true + downloadsOutputUri = null + return + } + val content = Utils.getFileContents(zipFilePath) + if (content == null) { + Log.w(TAG, "Failed to read zip file '$zipFile'") + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = String.format(ctx.getString(R.string.backup_failed), + Utils.fileNameOfUri(ctx, downloadsOutputUri!!)) + showAlert.value = true + downloadsOutputUri = null + return + } + if (!Utils.encryptToUri(ctx, downloadsOutputUri!!, content, password)) { + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = String.format(ctx.getString(R.string.backup_failed), + Utils.fileNameOfUri(ctx, downloadsOutputUri!!)) + showAlert.value = true + downloadsOutputUri = null + return + } + alertTitle.value = ctx.getString(R.string.info) + alertMessage.value = String.format(ctx.getString(R.string.backed_up), + Utils.fileNameOfUri(ctx, downloadsOutputUri!!)) + showAlert.value = true + Utils.deleteFile(File(zipFilePath)) + downloadsOutputUri = null +} + +private fun restore(ctx: Context, password: String, onRestartApp: () -> Unit) { + val zipFile = ctx.getString(R.string.app_name) + ".zip" + val zipFilePath = BaresipService.filesPath + "/$zipFile" + val zipData = Utils.decryptFromUri(ctx, downloadsInputUri!!, password) + if (zipData == null) { + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = String.format(ctx.getString(R.string.restore_failed), + Utils.fileNameOfUri(ctx, downloadsOutputUri!!)) + showAlert.value = true + downloadsOutputUri = null + return + } + if (!Utils.putFileContents(zipFilePath, zipData)) { + Log.w(TAG, "Failed to write zip file '$zipFile'") + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = String.format(ctx.getString(R.string.restore_failed), + Utils.fileNameOfUri(ctx, downloadsOutputUri!!)) + showAlert.value = true + downloadsOutputUri = null + return + } + if (!Utils.unZip(zipFilePath)) { + Log.w(TAG, "Failed to unzip file '$zipFile'") + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = String.format( + ctx.getString(R.string.restore_unzip_failed), + "baresip", + BuildConfig.VERSION_NAME + ) + showAlert.value = true + downloadsOutputUri = null + return + } + Utils.deleteFile(File(zipFilePath)) + + File("${BaresipService.filesPath}/recordings").walk().forEach { + if (it.name.startsWith("dump")) + Utils.deleteFile(it) + } + + dialogTitle.value = ctx.getString(R.string.info) + dialogMessage.value = ctx.getString(R.string.restored) + positiveText.value = ctx.getString(R.string.restart) + onPositiveClicked.value = { + onRestartApp() + showDialog.value = false + } + negativeText.value = ctx.getString(R.string.cancel) + onNegativeClicked.value = { + showDialog.value = false + } + showDialog.value = true + + downloadsOutputUri = null +} + +private fun abandonAudioFocus(ctx: Context) { + if (Build.VERSION.SDK_INT < 31) { + if (callRunnable != null) { + callHandler.removeCallbacks(callRunnable!!) + callRunnable = null + BaresipService.abandonAudioFocus(ctx) + } + } else { + if (audioModeChangedListener != null) { + val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + am.removeOnModeChangedListener(audioModeChangedListener!!) + audioModeChangedListener = null + BaresipService.abandonAudioFocus(ctx) + } + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/Message.kt b/app/src/main/kotlin/com/tutpro/baresip/Message.kt index f0670601..0c381062 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Message.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Message.kt @@ -52,7 +52,7 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim fun deleteAorMessage(aor: String, time: Long) { val updatedMessages = BaresipService.messages.toMutableList() - for (message in updatedMessages) + for (message in updatedMessages.reversed()) if (message.aor == aor && message.timeStamp == time) { updatedMessages.remove(message) BaresipService.messages = updatedMessages.toList() @@ -72,7 +72,7 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim fun updateAorMessage(aor: String, time: Long) { val updatedMessages = BaresipService.messages.toMutableList() - for (message in updatedMessages) + for (message in updatedMessages.reversed()) if (message.aor == aor && message.timeStamp == time) { message.new = false BaresipService.messages = updatedMessages.toList() @@ -81,6 +81,35 @@ class Message(val aor: String, val peerUri: String, val message: String, val tim } } + fun unreadMessages(aor: String): Boolean { + for (message in BaresipService.messages.reversed()) + if (message.aor == aor && message.new) + return true + return false + } + + fun unreadMessagesFromPeer(aor: String, peerUri: String): Boolean { + for (message in BaresipService.messages.reversed()) + if (message.aor == aor && message.peerUri == peerUri && message.new) + return true + return false + } + + fun updateMessagesFromPearRead(aor: String, peerUri: String): Boolean { + val updatedMessages = BaresipService.messages.toMutableList() + var updated = false + for (message in updatedMessages) + if (message.aor == aor && message.peerUri == peerUri && message.new) { + message.new = false + updated = true + } + if (updated) { + BaresipService.messages = updatedMessages.toList() + save() + } + return updated + } + fun save() { val file = File(BaresipService.filesPath, "messages") try { diff --git a/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt new file mode 100644 index 00000000..f5541fb9 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt @@ -0,0 +1,1428 @@ +package com.tutpro.baresip + +import android.Manifest +import android.app.Activity +import android.app.Activity.RESULT_OK +import android.app.role.RoleManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Context.POWER_SERVICE +import android.content.Context.ROLE_SERVICE +import android.content.Intent +import android.content.pm.PackageManager +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build.VERSION +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.statusBars +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.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.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.LaunchedEffect +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.app.ActivityCompat.shouldShowRequestPermissionRationale +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.tutpro.baresip.CustomElements.AlertDialog +import com.tutpro.baresip.CustomElements.LabelText +import com.tutpro.baresip.CustomElements.verticalScrollbar +import com.tutpro.baresip.Utils.copyInputStreamToFile +import java.io.File +import java.io.FileInputStream +import java.util.Locale + +private var restart = false +private val showRestartDialog = mutableStateOf(false) +private var save = false + +fun NavGraphBuilder.settingsScreenRoute( + navController: NavController, + onRestartApp: () -> Unit +) { + composable("settings") { + val ctx = LocalContext.current + SettingsScreen( + navController = navController, + onBack = { navController.popBackStack() }, + checkOnClick = { + checkOnClick(ctx) + if (restart) + showRestartDialog.value = true + else + navController.popBackStack() + }, + onRestartApp = onRestartApp + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScreen( + navController: NavController, + onBack: () -> Unit, + checkOnClick: () -> Unit, + onRestartApp: () -> Unit +) { + val activity = LocalActivity.current + val viewModel: ViewModel = viewModel(activity as ComponentActivity) + val audioResult by viewModel.audioSettingsResult + LaunchedEffect(audioResult) { + audioResult?.let { result -> + Log.d("SettingsScreen", "Got result from AudioSettings: $result") + restart = restart || result + viewModel.clearAudioSettingsResult() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize().imePadding(), + containerColor = LocalCustomColors.current.background, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalCustomColors.current.background) + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + ) + ) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.configuration), + color = LocalCustomColors.current.light, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = LocalCustomColors.current.light + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + actions = { + IconButton(onClick = checkOnClick) { + Icon( + imageVector = Icons.Filled.Check, + tint = LocalCustomColors.current.light, + contentDescription = "Check" + ) + } + }, + colors = TopAppBarDefaults.mediumTopAppBarColors( + containerColor = LocalCustomColors.current.primary + ) + ) + } + } + ) { contentPadding -> + + if (showRestartDialog.value) { + AlertDialog( + showDialog = showRestartDialog, + title = stringResource(R.string.restart_request), + message = stringResource(R.string.config_restart), + positiveButtonText = stringResource(R.string.restart), + onPositiveClicked = { + onRestartApp() + }, + negativeButtonText = stringResource(R.string.cancel), + onNegativeClicked = { + navController.popBackStack() + } + ) + } + + SettingsContent(contentPadding, navController, activity, onRestartApp) + } +} + +private val dialogTitle = mutableStateOf("") +private val dialogMessage = mutableStateOf("") +private val positiveText = mutableStateOf("") +private val onPositiveClicked = mutableStateOf({}) +private val negativeText = mutableStateOf("") +private val onNegativeClicked = mutableStateOf({}) +private val showDialog = mutableStateOf(false) + +private val alertTitle = mutableStateOf("") +private val alertMessage = mutableStateOf("") +private val showAlert = mutableStateOf(false) + +private var oldAutoStart = false +private var newAutoStart = false +private var oldListenAddr = "" +private var newListenAddr = "" +private var oldAddressFamily = "" +private var newAddressFamily = "" +private var oldDnsServers = "" +private var newDnsServers = "" +private var oldTlsCertificateFile = false +private var newTlsCertificateFile = false +private var oldVerifyServer = false +private var newVerifyServer = false +private var oldCaFile = false +private var newCaFile = false +private var oldUserAgent = "" +private var newUserAgent = "" +private var oldRingtoneUri = "" +private var newRingtoneUri = "" +private var oldBatteryOptimizations = false +private var newBatteryOptimizations = false +private var oldDefaultDialer = false +private var newDefaultDialer = false +private var oldContactsMode = "" +private var newContactsMode = "" +private var oldDarkTheme = false +private var newDarkTheme = false +private var oldDebug = false +private var newDebug = false +private var oldSipTrace = false +private var newSipTrace = false + +@Composable +private fun SettingsContent( + contentPadding: PaddingValues, + navController: NavController, + activity: Activity, + onRestartApp: () -> Unit +) { + + if (showAlert.value) { + AlertDialog( + showDialog = showAlert, + title = alertTitle.value, + message = alertMessage.value, + positiveButtonText = stringResource(R.string.ok), + ) + } + + if (showDialog.value) + AlertDialog( + showDialog = showDialog, + title = dialogTitle.value, + message = dialogMessage.value, + positiveButtonText = positiveText.value, + onPositiveClicked = onPositiveClicked.value, + negativeButtonText = negativeText.value, + onNegativeClicked = onNegativeClicked.value, + ) + + val ctx = LocalContext.current + + oldAutoStart = Config.variable("auto_start") == "yes" + if (oldAutoStart && !isAppearOnTopPermissionGranted(LocalContext.current)) { + Config.replaceVariable("auto_start", "no") + oldAutoStart = false + save = true + } + newAutoStart = oldAutoStart + + oldListenAddr = Config.variable("sip_listen") + + oldAddressFamily = Config.variable("net_af").lowercase() + newAddressFamily = oldAddressFamily + + val dynamicDns = Config.variable("dyn_dns") + if (dynamicDns == "yes") { + oldDnsServers = "" + } else { + val servers = Config.variables("dns_server") + var serverList = "" + for (server in servers) + serverList += ", $server" + oldDnsServers = serverList.trimStart(',').trimStart(' ') + } + + val certFile = File(BaresipService.filesPath + "/cert.pem") + oldTlsCertificateFile = certFile.exists() + + oldVerifyServer = Config.variable("sip_verify_server") == "yes" + newVerifyServer = oldVerifyServer + + val caCertsFile = File(BaresipService.filesPath + "/ca_certs.crt") + oldCaFile = caCertsFile.exists() + + oldUserAgent = Config.variable("user_agent") + newUserAgent = oldUserAgent + + val powerManager = ctx.getSystemService(POWER_SERVICE) as PowerManager + oldBatteryOptimizations = powerManager + .isIgnoringBatteryOptimizations(ctx.packageName) == false + newBatteryOptimizations = oldBatteryOptimizations + + if (VERSION.SDK_INT >= 29) { + val roleManager = ctx.getSystemService(ROLE_SERVICE) as RoleManager + oldDefaultDialer = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) + } + + oldContactsMode = Config.variable("contacts_mode").lowercase() + newContactsMode = oldContactsMode + + oldDarkTheme = Preferences(ctx).displayTheme == AppCompatDelegate.MODE_NIGHT_YES + newDarkTheme = oldDarkTheme + + oldDebug = Config.variable("log_level") == "0" + newDebug = oldDebug + + oldSipTrace = BaresipService.sipTrace + newSipTrace = oldSipTrace + + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding) + .padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 8.dp) + .verticalScrollbar(scrollState) + .verticalScroll(state = scrollState), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StartAutomatically() + ListenAddress() + AddressFamily() + DnsServers() + TlsCertificateFile(activity) + VerifyServer() + CaFile(activity) + UserAgent() + AudioSettings(navController) + Contacts(activity) + Ringtone() + BatteryOptimizations() + DarkTheme() + if (VERSION.SDK_INT >= 29) + DefaultDialer() + Debug() + SipTrace() + Reset(onRestartApp) + } +} + +@Composable +private fun StartAutomatically() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.start_automatically), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.start_automatically) + alertMessage.value = ctx.getString(R.string.start_automatically_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var startAutomatically by remember { mutableStateOf(oldAutoStart) } + Switch( + checked = startAutomatically, + onCheckedChange = { + if (it) { + if (!isAppearOnTopPermissionGranted(ctx)) { + dialogTitle.value = ctx.getString(R.string.notice) + dialogMessage.value = ctx.getString(R.string.appear_on_top_permission) + positiveText.value = ctx.getString(R.string.ok) + onPositiveClicked.value = { + val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) + ctx.startActivity(intent) + } + negativeText.value = ctx.getString(R.string.cancel) + onNegativeClicked.value = { + negativeText.value = "" + } + showDialog.value = true + startAutomatically = false + } + else + startAutomatically = true + } + else + startAutomatically = false + newAutoStart = startAutomatically + } + ) + } +} + +@Composable +private fun ListenAddress() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + val ctx = LocalContext.current + var listenAddr by remember { mutableStateOf(oldListenAddr) } + newListenAddr = listenAddr + OutlinedTextField( + value = listenAddr, + placeholder = { Text(stringResource(R.string._0_0_0_0_5060)) }, + onValueChange = { + listenAddr = it + newListenAddr = listenAddr + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.listen_address) + alertMessage.value = ctx.getString(R.string.listen_address_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.listen_address)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun AddressFamily() { + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.address_family), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.address_family) + alertMessage.value = ctx.getString(R.string.address_family_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val familyNames = listOf("--", "IPv4", "IPv6") + val familyValues = listOf("", "ipv4", "ipv6") + val itemPosition = remember { + mutableIntStateOf(familyValues.indexOf(oldAddressFamily)) + } + Box { + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = familyNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + familyNames.forEachIndexed { index, family -> + DropdownMenuItem(text = { + Text(text = family) + }, + onClick = { + isDropDownExpanded.value = false + itemPosition.intValue = index + newAddressFamily = familyValues[index] + }) + if (index < 2) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + } + } + } + } +} + +@Composable +private fun DnsServers() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var dnsServers by remember { mutableStateOf(oldDnsServers) } + newDnsServers = dnsServers + OutlinedTextField( + value = dnsServers, + onValueChange = { + dnsServers = it + newDnsServers = dnsServers + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.dns_servers) + alertMessage.value = ctx.getString(R.string.dns_servers_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText + ), + label = { LabelText(stringResource(R.string.dns_servers)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun TlsCertificateFile(activity: Activity) { + + val ctx = LocalContext.current + + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) {} + + val certificateRequest = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + val certPath = BaresipService.filesPath + "/cert.pem" + val certFile = File(certPath) + if (it.resultCode == RESULT_OK) { + it.data?.data?.also { uri -> + try { + val inputStream = ctx.contentResolver.openInputStream(uri) as FileInputStream + certFile.copyInputStreamToFile(inputStream) + inputStream.close() + Config.replaceVariable("sip_certificate", certPath) + save = true + restart = true + } catch (e: Error) { + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = ctx.getString(R.string.read_cert_error) + ": " + e.message + showAlert.value = true + newTlsCertificateFile = false + } + } + } + else + newTlsCertificateFile = false + if (!newTlsCertificateFile) + Utils.deleteFile(certFile) + } + + val showAlertDialog = remember { mutableStateOf(false) } + + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.tls_certificate_file), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.tls_certificate_file) + alertMessage.value = ctx.getString(R.string.tls_certificate_file_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var tlsCertificateFile by remember { mutableStateOf(oldTlsCertificateFile) } + Switch( + checked = tlsCertificateFile, + onCheckedChange = { + tlsCertificateFile = it + newTlsCertificateFile = tlsCertificateFile + if (it) + if (VERSION.SDK_INT < 29) { + tlsCertificateFile = false + val permission = Manifest.permission.READ_EXTERNAL_STORAGE + when { + ContextCompat.checkSelfPermission(ctx, permission) == + PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "Read External Storage permission granted") + val downloadsPath = Utils.downloadsPath("cert.pem") + val content = Utils.getFileContents(downloadsPath) + if (content == null) { + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = ctx.getString(R.string.read_cert_error) + showAlert.value = true + return@Switch + } + val certPath = BaresipService.filesPath + "/cert.pem" + Utils.putFileContents(certPath, content) + Config.replaceVariable("sip_certificate", certPath) + tlsCertificateFile = true + save = true + restart = true + } + shouldShowRequestPermissionRationale(activity, permission) -> + showAlertDialog.value = true + else -> + requestPermissionLauncher.launch(permission) + } + } + else + Utils.selectInputFile(certificateRequest) + else { + Config.removeVariable("sip_certificate") + Utils.deleteFile(File(BaresipService.filesPath + "/cert.pem")) + save = true + restart = true + } + } + ) + } + + if (showAlertDialog.value) + AlertDialog( + showDialog = showAlertDialog, + title = stringResource(R.string.notice), + message = stringResource(R.string.no_read_permission), + positiveButtonText = stringResource(R.string.ok), + onPositiveClicked = { requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) }, + negativeButtonText = "", + onNegativeClicked = {}, + ) +} + +@Composable +private fun VerifyServer() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.verify_server), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.verify_server) + alertMessage.value = ctx.getString(R.string.verify_server_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var verifyServer by remember { mutableStateOf(oldVerifyServer) } + Switch( + checked = verifyServer, + onCheckedChange = { + verifyServer = it + newVerifyServer = verifyServer + } + ) + } +} + +@Composable +private fun CaFile(activity: Activity) { + + val ctx = LocalContext.current + + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) {} + + val caCertsRequest = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + val caCertsFile = File(BaresipService.filesPath + "/ca_certs.crt") + if (it.resultCode == RESULT_OK) + it.data?.data?.also { uri -> + try { + val inputStream = ctx.contentResolver.openInputStream(uri) as FileInputStream + caCertsFile.copyInputStreamToFile(inputStream) + inputStream.close() + restart = true + } catch (e: Error) { + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = ctx.getString(R.string.read_ca_certs_error) + ": " + e.message + showAlert.value = true + newCaFile = false + } + } + else + newCaFile = false + if (!newCaFile) caCertsFile.delete() + } + + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.tls_ca_file), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.tls_ca_file) + alertMessage.value = ctx.getString(R.string.tls_ca_file_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var caFile by remember { mutableStateOf(oldCaFile) } + Switch( + checked = caFile, + onCheckedChange = { + caFile = it + newCaFile = caFile + if (it) { + if (VERSION.SDK_INT < 29) { + caFile = false + val permission = Manifest.permission.READ_EXTERNAL_STORAGE + when { + ContextCompat.checkSelfPermission(ctx, permission) == PackageManager.PERMISSION_GRANTED -> { + Log.d(TAG, "Read External Storage permission granted") + val downloadsPath = Utils.downloadsPath("ca_certs.crt") + val content = Utils.getFileContents(downloadsPath) + if (content == null) { + alertTitle.value = ctx.getString(R.string.error) + alertMessage.value = ctx.getString(R.string.read_ca_certs_error) + showAlert.value = true + return@Switch + } + File(BaresipService.filesPath + "/ca_certs.crt").writeBytes(content) + caFile = true + restart = true + } + shouldShowRequestPermissionRationale(activity, permission) -> { + dialogTitle.value = ctx.getString(R.string.notice) + dialogMessage.value = ctx.getString(R.string.no_read_permission) + positiveText.value = ctx.getString(R.string.ok) + onPositiveClicked.value = { + requestPermissionLauncher.launch(permission) + } + negativeText.value = "" + showDialog.value = true + } + else -> + requestPermissionLauncher.launch(permission) + } + } + else + Utils.selectInputFile(caCertsRequest) + } + else { + Utils.deleteFile(File(BaresipService.filesPath + "/ca_certs.crt")) + restart = true + } + } + ) + } +} + +@Composable +private fun UserAgent() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + var userAgent by remember { mutableStateOf(oldUserAgent) } + newUserAgent = userAgent + OutlinedTextField( + value = userAgent, + placeholder = { Text(stringResource(R.string.user_agent)) }, + onValueChange = { + userAgent = it + newUserAgent = userAgent + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + alertTitle.value = ctx.getString(R.string.user_agent) + alertMessage.value = ctx.getString(R.string.user_agent_help) + showAlert.value = true + }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, color = LocalCustomColors.current.itemText), + label = { LabelText(stringResource(R.string.user_agent)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } +} + +@Composable +private fun AudioSettings(navController: NavController) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.audio_settings), + modifier = Modifier + .weight(1f) + .clickable { + navController.navigate("audio") + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + fontWeight = FontWeight. Bold + ) + } +} + +@Composable +private fun Ringtone() { + val ctx = LocalContext.current + oldRingtoneUri = if (Preferences(ctx).ringtoneUri == "") + RingtoneManager.getActualDefaultRingtoneUri(ctx, RingtoneManager.TYPE_RINGTONE).toString() + else + Preferences(ctx).ringtoneUri!! + newRingtoneUri = oldRingtoneUri + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + if (result.resultCode == 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) + newRingtoneUri = uri.toString() + } + } + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.ringtone), + 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, + ctx.getString(R.string.select_ringtone) + ) + intent.putExtra( + RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, + newRingtoneUri.toUri() + ) + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false) + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + launcher.launch(intent) + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun BatteryOptimizations() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + val batterySettingsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val powerManager = ctx.getSystemService(POWER_SERVICE) as PowerManager + newBatteryOptimizations = !powerManager.isIgnoringBatteryOptimizations(ctx.packageName) + } + Text(text = stringResource(R.string.battery_optimizations), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.battery_optimizations) + alertMessage.value = ctx.getString(R.string.battery_optimizations_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var battery by remember { mutableStateOf(oldBatteryOptimizations) } + Switch( + checked = battery, + onCheckedChange = { + battery = it + newBatteryOptimizations = battery + batterySettingsLauncher.launch(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + ) + } +} +@Composable +private fun Contacts(activity: Activity) { + val showAlertDialog = remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.contacts), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.contacts) + alertMessage.value = ctx.getString(R.string.contacts_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val isDropDownExpanded = remember { + mutableStateOf(false) + } + val contactNames = listOf("baresip", "Android", "Both") + val contactValues = listOf("baresip", "android", "both") + val itemPosition = remember { + mutableIntStateOf(contactValues.indexOf(oldContactsMode)) + } + val requestPermissionsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) {} + val contactsPermissions = arrayOf(Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS) + Box { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + isDropDownExpanded.value = true + } + ) { + Text(text = contactNames[itemPosition.intValue], + color = LocalCustomColors.current.itemText) + CustomElements.DrawDrawable(R.drawable.arrow_drop_down, + tint = LocalCustomColors.current.itemText) + } + DropdownMenu( + expanded = isDropDownExpanded.value, + onDismissRequest = { + isDropDownExpanded.value = false + }) { + contactNames.forEachIndexed { index, name -> + DropdownMenuItem(text = { + Text(text = name) + }, + onClick = { + isDropDownExpanded.value = false + val mode = contactValues[index] + if (mode != "baresip" && !Utils.checkPermissions(ctx, contactsPermissions)) { + dialogTitle.value = ctx.getString(R.string.consent_request) + dialogMessage.value = ctx.getString(R.string.contacts_consent) + positiveText.value = ctx.getString(R.string.accept) + onPositiveClicked.value = { + showDialog.value = false + newContactsMode = mode + if (ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.WRITE_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + ) { + Log.d(TAG, "Contacts permissions already granted") + } else { + if (shouldShowRequestPermissionRationale( + activity, Manifest.permission.READ_CONTACTS) || + shouldShowRequestPermissionRationale( + activity, Manifest.permission.WRITE_CONTACTS)) + showAlertDialog.value = true + else + requestPermissionsLauncher.launch( + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + ) + } + } + negativeText.value = ctx.getString(R.string.deny) + onNegativeClicked.value = { + itemPosition.intValue = contactValues.indexOf(oldContactsMode) + negativeText.value = "" + } + showDialog.value = true + } + else { + itemPosition.intValue = index + newContactsMode = contactValues[index] + } + }) + if (index < 2) + HorizontalDivider( + thickness = 1.dp, + color = LocalCustomColors.current.itemText + ) + } + } + } + if (showAlertDialog.value) + AlertDialog( + showDialog = showAlertDialog, + title = stringResource(R.string.notice), + message = stringResource(R.string.no_android_contacts), + positiveButtonText = stringResource(R.string.ok), + onPositiveClicked = { requestPermissionsLauncher.launch( + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ) + )}, + negativeButtonText = "", + onNegativeClicked = {}, + ) + } +} + +@Composable +private fun DarkTheme() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.dark_theme), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.dark_theme) + alertMessage.value = ctx.getString(R.string.dark_theme_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var darkTheme by remember { mutableStateOf(oldDarkTheme) } + Switch( + checked = darkTheme, + onCheckedChange = { + darkTheme = it + newDarkTheme = darkTheme + } + ) + } +} + +@RequiresApi(29) +@Composable +private fun DefaultDialer() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.default_phone_app), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.default_phone_app) + alertMessage.value = ctx.getString(R.string.default_phone_app_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + val roleManager = ctx.getSystemService(ROLE_SERVICE) as RoleManager + val dialerRoleRequest = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + Log.d(TAG, "dialerRoleRequest result: $result") + newDefaultDialer = roleManager.isRoleHeld(RoleManager.ROLE_DIALER) + } + var defaultDialer by remember { mutableStateOf(oldDefaultDialer) } + Switch( + checked = defaultDialer, + onCheckedChange = { + defaultDialer = it + newDefaultDialer = defaultDialer + if (it) { + if (!roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)) { + alertTitle.value = ctx.getString(R.string.alert) + alertMessage.value = ctx.getString(R.string.dialer_role_not_available) + showAlert.value = true + } + else + if (!roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) + dialerRoleRequest.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)) + } else { + try { + dialerRoleRequest.launch(Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS")) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "ActivityNotFound exception: ${e.message}") + } + } + } + ) + } +} + +@Composable +private fun Debug() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.debug), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.debug) + alertMessage.value = ctx.getString(R.string.debug_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var db by remember { mutableStateOf(oldDebug) } + Switch( + checked = db, + onCheckedChange = { + db = it + newDebug = db + } + ) + } +} + +@Composable +private fun SipTrace() { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.sip_trace), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.sip_trace) + alertMessage.value = ctx.getString(R.string.sip_trace_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var sipTrace by remember { mutableStateOf(oldSipTrace) } + Switch( + checked = sipTrace, + onCheckedChange = { + sipTrace = it + newSipTrace = sipTrace + } + ) + } +} + +@Composable +private fun Reset(onRestartApp: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + val ctx = LocalContext.current + Text(text = stringResource(R.string.reset_config), + modifier = Modifier + .weight(1f) + .clickable { + alertTitle.value = ctx.getString(R.string.reset_config) + alertMessage.value = ctx.getString(R.string.reset_config_help) + showAlert.value = true + }, + color = LocalCustomColors.current.itemText, + fontSize = 18.sp) + var reset by remember { mutableStateOf(false) } + Switch( + checked = reset, + onCheckedChange = { + dialogTitle.value = ctx.getString(R.string.confirmation) + dialogMessage.value = ctx.getString(R.string.reset_config_alert) + positiveText.value = ctx.getString(R.string.reset) + onPositiveClicked.value = { + Config.reset() + onRestartApp() + } + negativeText.value = ctx.getString(R.string.cancel) + onNegativeClicked.value = { + reset = false + negativeText.value = "" + } + showDialog.value = true + } + ) + } +} + +private fun checkOnClick(ctx: Context) { + + if (oldAutoStart != newAutoStart) { + Config.replaceVariable( + "auto_start", + if (newAutoStart) "yes" else "no" + ) + save = true + } + + val listenAddr = newListenAddr.trim() + if (listenAddr != oldListenAddr) { + if ((listenAddr != "") && !Utils.checkIpPort(listenAddr)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = "${ctx.getString(R.string.invalid_listen_address)}: $listenAddr" + showAlert.value = true + return + } + Config.replaceVariable("sip_listen", listenAddr) + save = true + restart = true + } + + if (oldAddressFamily != newAddressFamily) { + Config.replaceVariable("net_af", newAddressFamily) + save = true + restart = true + } + + var dnsServers = newDnsServers.lowercase(Locale.ROOT).replace(" ", "") + dnsServers = addMissingPorts(dnsServers) + if (dnsServers != oldDnsServers.replace(" ", "")) { + if (!checkDnsServers(dnsServers)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = "${ctx.getString(R.string.invalid_dns_servers)}: $dnsServers" + showAlert.value = true + return + } + Config.removeVariable("dns_server") + if (dnsServers.isNotEmpty()) { + for (server in dnsServers.split(",")) + Config.addVariable("dns_server", server) + Config.replaceVariable("dyn_dns", "no") + if (Api.net_use_nameserver(dnsServers) != 0) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = "${ctx.getString(R.string.failed_to_set_dns_servers)}: $dnsServers" + showAlert.value = true + return + } + } else { + Config.replaceVariable("dyn_dns", "yes") + Config.updateDnsServers(BaresipService.dnsServers) + } + // Api.net_dns_debug() + save = true + } + + if (oldVerifyServer != newVerifyServer) { + Config.replaceVariable("sip_verify_server", if (newVerifyServer) "yes" else "no") + Api.config_verify_server_set(newVerifyServer) + save = true + } + + newUserAgent = newUserAgent.trim() + if (newUserAgent != oldUserAgent) { + if ((newUserAgent != "") && !Utils.checkServerVal(newUserAgent)) { + alertTitle.value = ctx.getString(R.string.notice) + alertMessage.value = "${ctx.getString(R.string.invalid_user_agent)}: $newUserAgent" + showAlert.value = true + return + } + if (newUserAgent != "") + Config.replaceVariable("user_agent", newUserAgent) + else + Config.removeVariable("user_agent") + save = true + restart = true + } + + Log.d(TAG, "Old/new ringtone: $oldRingtoneUri / $newRingtoneUri") + if (newRingtoneUri != oldRingtoneUri) { + Preferences(ctx).ringtoneUri = newRingtoneUri + BaresipService.rt = RingtoneManager.getRingtone(ctx, newRingtoneUri.toUri()) + } + if (oldContactsMode != newContactsMode) { + Config.replaceVariable("contacts_mode", newContactsMode) + BaresipService.contactsMode = newContactsMode + val baresipService = Intent(ctx, BaresipService::class.java) + when (newContactsMode) { + "baresip" -> { + BaresipService.androidContacts.value = listOf() + Contact.restoreBaresipContacts() + baresipService.action = "Stop Content Observer" + } + "android" -> { + BaresipService.baresipContacts.value = mutableListOf() + Contact.loadAndroidContacts(ctx) + baresipService.action = "Start Content Observer" + } + "both" -> { + Contact.restoreBaresipContacts() + Contact.loadAndroidContacts(ctx) + baresipService.action = "Start Content Observer" + } + } + Contact.contactsUpdate() + ctx.startService(baresipService) + save = true + } + + val newDisplayTheme = if (newDarkTheme) + AppCompatDelegate.MODE_NIGHT_YES + else + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + if (oldDarkTheme != newDarkTheme) { + Preferences(ctx).displayTheme = newDisplayTheme + BaresipService.darkTheme.value = newDarkTheme + AppCompatDelegate.setDefaultNightMode(newDisplayTheme) + Config.replaceVariable("dark_theme", + if (newDarkTheme) "yes" else "no") + save = true + } + + if (oldDebug != newDebug) { + val logLevelString = if (newDebug) "0" else "2" + Config.replaceVariable("log_level", logLevelString) + Api.log_level_set(logLevelString.toInt()) + Log.logLevelSet(logLevelString.toInt()) + save = true + } + + if (oldSipTrace != newSipTrace) { + BaresipService.sipTrace = newSipTrace + Api.uag_enable_sip_trace(newSipTrace) + } + + if (save) Config.save() +} + +private fun isAppearOnTopPermissionGranted(ctx: Context): Boolean { + return Settings.canDrawOverlays(ctx) +} + +private fun addMissingPorts(addressList: String): String { + if (addressList == "") return "" + var result = "" + for (addr in addressList.split(",")) + result = if (Utils.checkIpPort(addr)) { + "$result,$addr" + } else { + if (Utils.checkIpV4(addr)) + "$result,$addr:53" + else + "$result,[$addr]:53" + } + return result.substring(1) +} + +private fun checkDnsServers(dnsServers: String): Boolean { + if (dnsServers.isEmpty()) return true + for (server in dnsServers.split(",")) + if (!Utils.checkIpPort(server.trim())) return false + return true +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/TaskReceiver.kt b/app/src/main/kotlin/com/tutpro/baresip/TaskReceiver.kt index da22c457..0d6bd2a6 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/TaskReceiver.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/TaskReceiver.kt @@ -31,13 +31,13 @@ class TaskReceiver : BroadcastReceiver() { Api.account_set_regint(acc.accp, REGISTRATION_INTERVAL) Api.ua_register(ua.uap) acc.regint = Api.account_regint(acc.accp) - AccountsActivity.saveAccounts() + Account.saveAccounts() } else { Log.d(TAG, "TaskReceiver: un-registering $aor") Api.account_set_regint(acc.accp, 0) Api.ua_unregister(ua.uap) acc.regint = Api.account_regint(acc.accp) - AccountsActivity.saveAccounts() + Account.saveAccounts() } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt b/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt index d4e731d5..abc7df4b 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/UserAgent.kt @@ -45,6 +45,23 @@ class UserAgent(val uap: Long) { return null } + fun reRegister() { + this.status = R.drawable.circle_yellow + if (this.account.regint == 0) + Api.ua_unregister(this.uap) + else + Api.ua_register(this.uap) + } + + fun makeDefault() { + val index = uas.value.indexOf(this) + val updatedUas = uas.value.toMutableList() + updatedUas.removeAt(index) + updatedUas.add(0, this) + uas.value = updatedUas.toList() + uasStatus.value = statusMap() + } + companion object { fun ofAor(aor: String): UserAgent? { diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 4a1c082a..def6ee91 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -7,7 +7,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration -import android.graphics.Bitmap import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioManager @@ -62,7 +61,6 @@ import javax.crypto.SecretKeyFactory import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec -import androidx.core.graphics.scale object Utils { @@ -231,7 +229,7 @@ object Utils { fun checkUriUser(user: String): Boolean { val escaped = """%(\d|A|B|C|D|E|F|a|b|c|d|e|f){2}""".toRegex() escaped.replace(user, "").forEach { - if (!(it.isLetterOrDigit() || "-_.!~*\'()&=+\$,;?/".contains(it))) return false } + if (!(it.isLetterOrDigit() || "-_.!~*\'()&=+$,;?/".contains(it))) return false } return user.isNotEmpty() && !checkIpV4(user) && !checkIpV6(user) } @@ -239,7 +237,7 @@ object Utils { val parts = domain.split(".") for (p in parts) { if (p.endsWith("-") || p.startsWith("-") || - !Regex("^[-a-zA-Z0-9]+\$").matches(p)) + !Regex("^[-a-zA-Z0-9]+$").matches(p)) return false } return true @@ -333,7 +331,7 @@ object Utils { } fun isTelNumber(no: String): Boolean { - return no.isNotEmpty() && Regex("^([+][1-9])?[0-9- (),*#]{0,24}\$").matches(no) + return no.isNotEmpty() && Regex("^([+][1-9])?[0-9- (),*#]{0,24}$").matches(no) } fun isTelUri(uri: String): Boolean { @@ -383,7 +381,7 @@ object Utils { } private fun checkToken(token: String): Boolean { - return Regex("^[-a-zA-Z0-9.!%*_+`'~]+\$").matches(token) + return Regex("^[-a-zA-Z0-9.!%*_+`'~]+$").matches(token) } @Suppress("unused") @@ -585,22 +583,6 @@ object Utils { name } - fun saveBitmap(bitmap: Bitmap, file: File): Boolean { - if (file.exists()) file.delete() - try { - val out = FileOutputStream(file) - val scaledBitmap = bitmap.scale(96, 96) - scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - out.flush() - out.close() - Log.d(TAG, "Saved bitmap to ${file.absolutePath} of length ${file.length()}") - } catch (e: Exception) { - Log.e(TAG, "Failed to save bitmap to ${file.absolutePath}: $e") - return false - } - return true - } - class Crypto(val salt: ByteArray, val iter: Int, val iv: ByteArray, val data: ByteArray): Serializable { companion object { private const val serialVersionUID: Long = -29238082928391L @@ -805,11 +787,6 @@ object Utils { rnd.nextInt(256)) } - fun addActivity(activity: String) { - if ((BaresipService.activities.isEmpty()) || (BaresipService.activities[0] != activity)) - BaresipService.activities.add(0, activity) - } - fun requestDismissKeyguard(activity: Activity) { val kgm = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager kgm.requestDismissKeyguard(activity, null) @@ -1018,4 +995,39 @@ object Utils { Api.AAudio_close_stream() } + + /*fun listFilesInDirectory(directoryPath: String): List { + val directory = File(directoryPath) + if (!directory.exists()) { + Log.w(TAG, "Directory does not exist: $directoryPath") + return emptyList() + } + if (!directory.isDirectory) { + Log.w(TAG, "Path is not a directory: $directoryPath") + return emptyList() + } + val files = directory.listFiles() + if (files == null) { + Log.e( + TAG, + "Failed to list files in directory (listFiles returned null): $directoryPath" + ) + return emptyList() + } + return files.filter { it.isFile } + }*/ + + /*@SuppressLint("RestrictedApi") + fun printBackStack(navController: NavController) { + Log.e(TAG, "---- Current Navigation Back Stack ----") + navController.currentBackStack.value.forEachIndexed { index, navBackStackEntry -> + val route = navBackStackEntry.destination.route + val arguments = navBackStackEntry.arguments?.let { bundle -> + bundle.keySet().joinToString(", ") { key -> "$key=${bundle.get(key)}" } + } ?: "null" + Log.e(TAG, "$index: Route='${route}', Args=[$arguments], ID=${navBackStackEntry.id}") + } + Log.e(TAG, "--------------------------------------") + }*/ + } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt b/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt index 402cf3ff..d0aa2d72 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ViewModel.kt @@ -1,10 +1,23 @@ package com.tutpro.baresip import android.app.Application +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.AndroidViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import androidx.compose.runtime.State +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +sealed class NavigationCommand { + data class NavigateToChat(val aor: String, val peer: String?) : NavigationCommand() + data class NavigateToCalls(val aor: String) : NavigationCommand() + object NavigateToHome: NavigationCommand() + // Add other navigation commands as needed +} class ViewModel(application: Application) : AndroidViewModel(application) { @@ -15,4 +28,96 @@ class ViewModel(application: Application) : AndroidViewModel(application) { _selectedAor.value = newValue } + private val _speakerIcon = MutableStateFlow(R.drawable.speaker_off) + val speakerIcon: StateFlow = _speakerIcon.asStateFlow() + + fun updateSpeakerIcon(iconResId: Int) { + _speakerIcon.value = iconResId + } + + private val _micIcon = MutableStateFlow(R.drawable.mic_on) + val micIcon: StateFlow = _micIcon.asStateFlow() + + fun updateMicIcon(iconResId: Int) { + _micIcon.value = iconResId + } + + private val _vmIcon = MutableStateFlow(R.drawable.voicemail) + val vmIcon: StateFlow = _vmIcon.asStateFlow() + + fun updateVmIcon(newIcon: Int) { + _vmIcon.value = newIcon + } + + private val _showVmIcon = MutableStateFlow(false) + val showVmIcon: StateFlow = _showVmIcon.asStateFlow() + + fun updateShowVmIcon(show: Boolean) { + _showVmIcon.value = show + } + + private val _messagesIcon = MutableStateFlow(R.drawable.messages) + val messagesIcon: StateFlow = _messagesIcon.asStateFlow() + + fun updateMessagesIcon(newIcon: Int) { + _messagesIcon.value = newIcon + } + + private val _callsIcon = MutableStateFlow(R.drawable.calls) + val callsIcon: StateFlow = _callsIcon.asStateFlow() + + fun updateCallsIcon(newIcon: Int) { + _callsIcon.value = newIcon + } + + private val _dialpadIcon = MutableStateFlow(R.drawable.dialpad_off) + val dialpadIcon: StateFlow = _dialpadIcon.asStateFlow() + + fun updateDialpadIcon(newIcon: Int) { + _dialpadIcon.value = newIcon + } + + private val _audioSettingsResult = mutableStateOf(null) + val audioSettingsResult: State get() = _audioSettingsResult + + fun setAudioSettingsResult(result: Boolean) { + _audioSettingsResult.value = result + } + + fun clearAudioSettingsResult() { + _audioSettingsResult.value = null + } + + private val _selectedCallRow = MutableStateFlow(null) + + fun selectCallRow(callRow: CallRow) { + _selectedCallRow.value = callRow + } + + fun consumeSelectedCallRow(): CallRow? { + val callRow = _selectedCallRow.value + _selectedCallRow.value = null + return callRow + } + + private val _navigationCommand = MutableSharedFlow() + val navigationCommand = _navigationCommand.asSharedFlow() + + fun onNewMessageReceived(aor: String, peer: String) { + viewModelScope.launch { + _navigationCommand.emit(NavigationCommand.NavigateToChat(aor, peer)) + } + } + + fun navigateToCalls(aor: String) { + viewModelScope.launch { + _navigationCommand.emit(NavigationCommand.NavigateToCalls(aor)) + } + } + + fun navigateToHome() { + viewModelScope.launch { + _navigationCommand.emit(NavigationCommand.NavigateToHome) + } + } } diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index a486fb6f..a8c92444 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -26,6 +26,7 @@
  • Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.
  • Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.
  • Voit lisätä/poistaa yhteystiedon avatar-kuvan koskettamalla yhteystiedon ikonia lyhyesti/pitkästi.
  • +
  • Audio-koodekin saa pitkällä kosketuksella käytöön/pois käytöstä.
  • Katso lisätietoja Wiki-sivulta.
  • Tietosuoja

    @@ -69,6 +70,7 @@
  • Voit lisätä puheluiden ja viestien kohteet yhteystietoihin pitkällä kosketuksella.
  • Pitkillä kosketuksilla voit myös poistaa puheluita, viestiketjuja, viestejä ja yhteystietoja.
  • Voit lisätä/poistaa yhteystiedon avatar-kuvan koskettamalla yhteystiedon ikonia lyhyesti/pitkästi.
  • +
  • Audio- ja video-koodekin saa pitkällä kosketuksella käytöön/pois käytöstä.
  • Katso lisätietoja Wiki-sivulta.
  • Tunnetut ongelmat

    @@ -255,6 +257,8 @@ Vastaa Hylkää + Vastaa + Talleta Puhelu soittajalta Vastaamaton puhelu soittajalta Vastaamattomia puheluita @@ -593,8 +597,13 @@ Et voi käyttää Androidin yhteystietoja ilman Yhteystiedot-lupaa. Audiofokus on evätty! Tarvittavat luvat - baresip tarvitsee Mikrofoni-luvan puheluita varten ja Lähellä olevat laitteet -luvan Bluetooth-mikrofonin/kaiuttimen havaitsemista varten ja Ilmoitukset-luvan ilmoitusten lähettämistä varten. - baresip+ tarvitsee Mikrofoni-luvan puheluita varten, Kamera-luvan videopuheluita varten ja Lähellä olevat laitteet -luvan Bluetooth-mikrofonin/kaiuttimen havaitsemista varten ja Ilmoitukset-luvan ilmoitusten näyttämistä varten. + baresip tarvitsee Mikrofoni-luvan puheluita varten, Lähellä olevat laitteet -luvan + Bluetooth-mikrofonin/kaiuttimen havaitsemista varten, Ilmoitukset-luvan ilmoitusten lähettämistä varten + ja Android versiossa 9 Tallennustila-luvan Talleta/Palauta-toimintoja varten. + baresip+ tarvitsee Mikrofoni-luvan puheluita varten, + Kamera-luvan videopuheluita varten, Lähellä olevat laitteet -luvan Bluetooth-mikrofonin/kaiuttimen + havaitsemista varten, Ilmoitukset-luvan ilmoitusten näyttämistä varten ja Android versiossa 9 + Tallennustila-luvan Talleta/Palauta-toimintoja varten. Puheluiden talletus Jos aktivoitu, uudet soitetut ja vastatut puhelut talletetaan. Tallennukset voi kuunnella Puhelutiedot-sivulla. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 630da876..d7e9ed2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,7 @@
  • Peers of calls and messages can be added to contacts by long touches.
  • Long touches can also be used to remove calls, chats, messages, and contacts.
  • Touch/long touch on contact icon can be used to install/remove image avatar.
  • +
  • Long touch on an audio codec can be used to enable/disable the codec.
  • See Wiki for more information.
  • Privacy Policy

    @@ -72,6 +73,7 @@
  • Peers of calls and messages can be added to contacts by long touches.
  • Long touches can also be used to remove calls, chats, messages, and contacts.
  • Touch/long touch of contact icon can be used to install/remove image avatar.
  • +
  • Long touch on an audio or video codec can be used to enable/disable the codec.
  • See Wiki for more information.
  • @@ -240,6 +242,8 @@ Answer Reject + Reply + Save Incoming call from Missed call from Missed calls @@ -575,11 +579,13 @@ Audio focus denied! Permissions rationale baresip needs \"Microphone\" permission for voice calls, - \"Nearby devices\" permission for Bluetooth microphone/speaker detection, and - \"Notifications\" permission for posting notifications. - baresip+ needs \"Microphone\" permission for voice calls, - \"Camera\" permission for video calls, \"Nearby devices\" permission for Bluetooth - microphone/speaker detection, and \"Notifications\" permission for posting notifications. + \"Nearby devices\" permission for Bluetooth microphone/speaker detection, + \"Notifications\" permission for posting notifications, and in Android 9 + \"Storage\" permission for Backup/Restore operations.
    + baresip+ needs \"Microphone\" permission + for voice calls, \"Camera\" permission for video calls, \"Nearby devices\" permission + for Bluetooth microphone/speaker detection, \"Notifications\" permission for posting + notifications, and in Android 9 \"Storage\" permissions for Backup/Restore operations. Call Recording If activated, new incoming and outgoing calls will be recorded. Recordings can be played on Call Details page @@ -587,4 +593,4 @@ If activated during call, microphone is muted. Speakerphone If activated, audio is played via device speakerphone. - \ No newline at end of file + diff --git a/libbaresip-android b/libbaresip-android index d244a177..75d9f03a 160000 --- a/libbaresip-android +++ b/libbaresip-android @@ -1 +1 @@ -Subproject commit d244a177e9a1cd344064f45f450de4110376d1b2 +Subproject commit 75d9f03a02f35c29a38f529b4bf9b594f94e2e06