Added email field to baresip contact

Added possibility to send email to contact and call or chat peer
Added possibility to import contacts from vcf file to baresip contacts
This commit is contained in:
Juha Heinanen
2026-05-18 12:04:52 +03:00
parent aa0098b16b
commit 8f95b3609c
10 changed files with 415 additions and 97 deletions
@@ -33,13 +33,15 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -111,6 +113,7 @@ private data class ScreenState(
val newId: Long = 0, val newId: Long = 0,
val name: String = "", val name: String = "",
val uris: List<String> = emptyList(), val uris: List<String> = emptyList(),
val email: String = "",
val color: Int = 0, val color: Int = 0,
val avatarImageUri: String? = null, val avatarImageUri: String? = null,
val tmpAvatarFile: File? = null, val tmpAvatarFile: File? = null,
@@ -141,6 +144,7 @@ private fun ContactScreen(
new = true, new = true,
name = "", name = "",
uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg), uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg),
email = "",
favorite = false, favorite = false,
android = BaresipService.contactsMode == "android", android = BaresipService.contactsMode == "android",
color = Utils.randomColor(), color = Utils.randomColor(),
@@ -156,6 +160,7 @@ private fun ContactScreen(
new = false, new = false,
name = uriOrNameArg, name = uriOrNameArg,
uris = contact.uris, uris = contact.uris,
email = contact.email,
favorite = contact.favorite, favorite = contact.favorite,
android = false, android = false,
color = contact.color, color = contact.color,
@@ -186,9 +191,11 @@ private fun ContactScreen(
currentState = screenState, currentState = screenState,
uriOrNameArg = uriOrNameArg, uriOrNameArg = uriOrNameArg,
) )
if (result) if (result) {
navController.previousBackStackEntry?.savedStateHandle?.set("scrollToContact", screenState.name)
navController.navigateUp() navController.navigateUp()
} }
}
BackHandler(enabled = true) { BackHandler(enabled = true) {
onBack() onBack()
@@ -279,12 +286,14 @@ private fun ContactContent(
) )
} }
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(contentPadding) .padding(contentPadding)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp), .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 52.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Avatar( Avatar(
@@ -326,6 +335,10 @@ private fun ContactContent(
uris = screenState.uris, uris = screenState.uris,
onUrisChange = { newUris -> onStateChange(screenState.copy(uris = newUris)) } onUrisChange = { newUris -> onStateChange(screenState.copy(uris = newUris)) }
) )
ContactEmail(
email = screenState.email,
onEmailChange = { newEmail -> onStateChange(screenState.copy(email = newEmail)) }
)
Favorite( Favorite(
ctx = ctx, ctx = ctx,
favorite = screenState.favorite, favorite = screenState.favorite,
@@ -491,8 +504,8 @@ private fun ContactUris(uris: List<String>, onUrisChange: (List<String>) -> Unit
modifier = Modifier.padding(start = 4.dp) modifier = Modifier.padding(start = 4.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Remove,
contentDescription = "Delete", contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
@@ -516,6 +529,19 @@ private fun ContactUris(uris: List<String>, onUrisChange: (List<String>) -> Unit
} }
} }
@Composable
private fun ContactEmail(email: String, onEmailChange: (String) -> Unit) {
OutlinedTextField(
value = email,
placeholder = { Text(stringResource(R.string.email)) },
onValueChange = onEmailChange,
modifier = Modifier.fillMaxWidth(),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp),
label = { Text(stringResource(R.string.email)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
}
@Composable @Composable
private fun Favorite(ctx: Context, favorite: Boolean, onFavoriteChange: (Boolean) -> Unit) { private fun Favorite(ctx: Context, favorite: Boolean, onFavoriteChange: (Boolean) -> Unit) {
Row( Row(
@@ -633,6 +659,7 @@ private fun checkOnClick(
Contact.BaresipContact( Contact.BaresipContact(
newName, newName,
newUris, newUris,
currentState.email.trim(),
currentState.color, currentState.color,
idToUse, idToUse,
currentState.favorite currentState.favorite
@@ -743,6 +770,16 @@ private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Bo
.withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name) .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name)
.build()) .build())
if (contact.email.isNotEmpty()) {
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Email.ADDRESS, contact.email)
.withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME)
.build())
}
for (uri in contact.uris) { for (uri in contact.uris) {
val mimeType = if (uri.startsWith("sip:")) val mimeType = if (uri.startsWith("sip:"))
CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE
@@ -779,6 +816,10 @@ private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Bo
} }
private fun updateAndroidContact(ctx: Context, rawContactId: Long, contact: Contact.BaresipContact) { private fun updateAndroidContact(ctx: Context, rawContactId: Long, contact: Contact.BaresipContact) {
if (contact.email.isNotEmpty()) {
if (updateAndroidEmail(ctx, rawContactId, contact.email) == 0)
addAndroidEmail(ctx, rawContactId, contact.email)
}
for (uri in contact.uris) for (uri in contact.uris)
if (updateAndroidUri(ctx, rawContactId, uri) == 0) if (updateAndroidUri(ctx, rawContactId, uri) == 0)
addAndroidUri(ctx, rawContactId, uri) addAndroidUri(ctx, rawContactId, uri)
@@ -843,6 +884,36 @@ private fun addAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap)
} }
} }
private fun addAndroidEmail(ctx: Context, rawContactId: Long, email: String) {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Email.ADDRESS, email)
.withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME)
.build())
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of Android email failed: ${e.message}")
}
}
private fun updateAndroidEmail(ctx: Context, rawContactId: Long, email: String): Int {
val contentValues = ContentValues()
contentValues.put(CommonDataKinds.Email.ADDRESS, email)
val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " +
"${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Email.CONTENT_ITEM_TYPE}'"
return try {
ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null)
} catch (e: Exception) {
Log.e(TAG, "updateAndroidEmail failed: ${e.message}")
0
}
}
private fun updateAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap?): Int { private fun updateAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap?): Int {
val photoBytes = if (photoBits == null) val photoBytes = if (photoBits == null)
null null
@@ -71,6 +71,7 @@ import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import coil.compose.AsyncImage import coil.compose.AsyncImage
import androidx.core.net.toUri
import com.tutpro.baresip.CustomElements.AlertDialog import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.SelectableAlertDialog import com.tutpro.baresip.CustomElements.SelectableAlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar import com.tutpro.baresip.CustomElements.verticalScrollbar
@@ -279,6 +280,8 @@ private fun Calls(
val message = remember { mutableStateOf("") } val message = remember { mutableStateOf("") }
val secondButtonText = remember { mutableStateOf("") } val secondButtonText = remember { mutableStateOf("") }
val secondAction = remember { mutableStateOf({}) } val secondAction = remember { mutableStateOf({}) }
val thirdButtonText = remember { mutableStateOf("") }
val thirdAction = remember { mutableStateOf({}) }
val lastButtonText = remember { mutableStateOf("") } val lastButtonText = remember { mutableStateOf("") }
val lastAction = remember { mutableStateOf({}) } val lastAction = remember { mutableStateOf({}) }
@@ -289,6 +292,8 @@ private fun Calls(
firstButtonText = stringResource(R.string.cancel), firstButtonText = stringResource(R.string.cancel),
secondButtonText = secondButtonText.value, secondButtonText = secondButtonText.value,
onSecondClicked = secondAction.value, onSecondClicked = secondAction.value,
thirdButtonText = thirdButtonText.value,
onThirdClicked = thirdAction.value,
lastButtonText = lastButtonText.value, lastButtonText = lastButtonText.value,
onLastClicked = lastAction.value, onLastClicked = lastAction.value,
) )
@@ -345,11 +350,20 @@ private fun Calls(
else else
Log.w(TAG, "onClickListener did not find UA for $aor") Log.w(TAG, "onClickListener did not find UA for $aor")
val peerName = Utils.friendlyUri(ctx, peerUri, account) val peerName = Utils.friendlyUri(ctx, peerUri, account)
message.value = String.format(ctx.getString(R.string.contact_action_question), peerName) val contact = Contact.findContact(peerUri)
if (contact is Contact.BaresipContact && contact.email.isNotEmpty())
message.value = String.format(
ctx.getString(R.string.contact_email_action_question),
peerName
)
else
message.value = String.format(
ctx.getString(R.string.contact_action_question),
peerName
)
secondButtonText.value = ctx.getString(R.string.call) secondButtonText.value = ctx.getString(R.string.call)
secondAction.value = { secondAction.value = {
if (ua != null) { if (ua != null) {
val contact = Contact.findContact(peerUri)
val uris = if (contact is Contact.BaresipContact) { val uris = if (contact is Contact.BaresipContact) {
if (ua.account.isMobile) if (ua.account.isMobile)
contact.uris.filter { it.startsWith("tel:") } contact.uris.filter { it.startsWith("tel:") }
@@ -381,10 +395,9 @@ private fun Calls(
} }
} }
} }
lastButtonText.value = ctx.getString(R.string.send_message) thirdButtonText.value = ctx.getString(R.string.send_message)
lastAction.value = { thirdAction.value = {
if (ua != null) { if (ua != null) {
val contact = Contact.findContact(peerUri)
val uris = if (contact is Contact.BaresipContact) { val uris = if (contact is Contact.BaresipContact) {
if (ua.account.isMobile) if (ua.account.isMobile)
contact.uris.filter { it.startsWith("tel:") } contact.uris.filter { it.startsWith("tel:") }
@@ -432,6 +445,21 @@ private fun Calls(
} }
} }
} }
if (contact is Contact.BaresipContact && contact.email.isNotEmpty()) {
lastButtonText.value = ctx.getString(R.string.send_email)
lastAction.value = {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:${contact.email}".toUri()
}
try {
ctx.startActivity(emailIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start email activity: ${e.message}")
}
}
} else {
lastButtonText.value = ""
}
showDialog.value = true showDialog.value = true
}, },
onLongClick = { onLongClick = {
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -74,6 +75,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
@@ -216,7 +218,6 @@ private fun TopAppBar(
peerUri: String peerUri: String
) { ) {
val aor = account.aor val aor = account.aor
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
@@ -245,10 +246,10 @@ private fun TopAppBar(
onClick = { onClick = {
val ua = UserAgent.ofAor(account.aor) val ua = UserAgent.ofAor(account.aor)
if (ua != null) { if (ua != null) {
val intent = Intent(ctx, MainActivity::class.java) val callIntent = Intent(ctx, MainActivity::class.java)
intent.putExtra("uap", ua.uap) .putExtra("uap", ua.uap)
intent.putExtra("peer", peerUri) .putExtra("peer", peerUri)
handleIntent(ctx, viewModel, intent, "call") handleIntent(ctx, viewModel, callIntent, "call")
navController.navigate("main") { navController.navigate("main") {
popUpTo("main") popUpTo("main")
launchSingleTop = true launchSingleTop = true
@@ -263,6 +264,34 @@ private fun TopAppBar(
contentDescription = "Call", contentDescription = "Call",
) )
} }
val contact = Contact.findContact(peerUri)
if (contact != null && contact is Contact.BaresipContact && contact.email.isNotEmpty())
IconButton(
onClick = {
val ua = UserAgent.ofAor(account.aor)
if (ua != null) {
val contact = Contact.findContact(peerUri)
if (contact != null && contact is Contact.BaresipContact &&
contact.email.isNotEmpty()) {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:${contact.email}".toUri()
}
try {
ctx.startActivity(emailIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start email activity: ${e.message}")
}
}
}
else
Log.w(TAG, "Call button onClick listener did not find UA for $aor")
}
) {
Icon(
imageVector = Icons.Outlined.Email,
contentDescription = "Email",
)
}
} }
) )
} }
@@ -433,6 +433,9 @@ private fun NewChatPeer(ctx: Context, navController: NavController, account: Acc
else else
chatPeer chatPeer
val uri = if (Utils.isTelUri(peerUri)) { val uri = if (Utils.isTelUri(peerUri)) {
if (account.isMobile)
peerUri
else
if (account.telProvider == "") { if (account.telProvider == "") {
alertTitle.value = ctx.getString(R.string.notice) alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = alertMessage.value =
@@ -441,7 +444,8 @@ private fun NewChatPeer(ctx: Context, navController: NavController, account: Acc
"" ""
} else } else
Utils.telToSip(peerUri, account) Utils.telToSip(peerUri, account)
} else }
else
Utils.uriComplete(peerUri, account.aor) Utils.uriComplete(peerUri, account.aor)
if (alertMessage.value.isEmpty()) { if (alertMessage.value.isEmpty()) {
if (!Utils.checkUri(uri)) { if (!Utils.checkUri(uri)) {
@@ -589,7 +593,7 @@ private fun NewChatPeer(ctx: Context, navController: NavController, account: Acc
showSuggestions = false showSuggestions = false
val peerText = newPeer.trim() val peerText = newPeer.trim()
if (peerText.isNotEmpty()) { if (peerText.isNotEmpty()) {
val uris = Contact.contactUris(peerText) val uris = Contact.contactUris(peerText, account.isMobile)
if (uris.isEmpty()) if (uris.isEmpty())
makeChat(ctx, navController, account, peerText) makeChat(ctx, navController, account, peerText)
else if (uris.size == 1) else if (uris.size == 1)
@@ -13,8 +13,8 @@ import java.util.ArrayList
sealed class Contact { sealed class Contact {
class BaresipContact(var name: String, val uris: ArrayList<String>, var color: Int, var id: Long, class BaresipContact(var name: String, val uris: ArrayList<String>, var email: String, var color: Int,
var favorite: Boolean): Contact() { var id: Long, var favorite: Boolean): Contact() {
var avatarImage: Bitmap? = null var avatarImage: Bitmap? = null
} }
@@ -57,7 +57,7 @@ sealed class Contact {
fun copy(): Contact { fun copy(): Contact {
val copy = when (this) { val copy = when (this) {
is BaresipContact -> is BaresipContact ->
BaresipContact(name, ArrayList(uris), color, id, favorite) BaresipContact(name, ArrayList(uris), email, color, id, favorite)
is AndroidContact -> is AndroidContact ->
AndroidContact(name, color, thumbnailUri, id, favorite) AndroidContact(name, color, thumbnailUri, id, favorite)
} }
@@ -101,16 +101,17 @@ sealed class Contact {
} }
// Return URIs of contact name // Return URIs of contact name
fun contactUris(name: String): ArrayList<String> { fun contactUris(name: String, tel: Boolean = false): ArrayList<String> {
val uris = ArrayList<String>() val uris = ArrayList<String>()
for (c in BaresipService.contacts) for (c in BaresipService.contacts)
when (c) { when (c) {
is BaresipContact -> { is BaresipContact -> {
if (c.name.equals(name, ignoreCase = true)) { if (c.name.equals(name, ignoreCase = true)) {
for (u in c.uris) for (u in c.uris) {
uris.add(u.removePrefix("<") if (tel && !u.startsWith("tel:"))
.replaceAfter(">", "") continue
.replace(">", "")) uris.add(u)
}
return uris return uris
} }
} }
@@ -155,8 +156,8 @@ sealed class Contact {
val avatarFiles = avatarFileNames() val avatarFiles = avatarFileNames()
var contents = "" var contents = ""
for (c in BaresipService.baresipContacts.value) { for (c in BaresipService.baresipContacts.value) {
contents += "\"${c.name}\" <${c.uris.joinToString(",")}>;id=${c.id};color=${c.color}" + contents += "\"${c.name}\" <${c.uris.joinToString(",")}>;email=${c.email};id=${c.id}" +
";favorite=${if (c.favorite) "yes" else "no"}\n" ";color=${c.color};favorite=${if (c.favorite) "yes" else "no"}\n"
avatarFiles.remove(c.id.toString() + ".png") avatarFiles.remove(c.id.toString() + ".png")
} }
Utils.putFileContents(BaresipService.filesPath + "/contacts", Utils.putFileContents(BaresipService.filesPath + "/contacts",
@@ -166,8 +167,8 @@ sealed class Contact {
} }
fun loadAndroidContacts(ctx: Context) { fun loadAndroidContacts(ctx: Context) {
// If phone type is needed, add DATA2 to projection. Then phone type can be get from // If phone type is needed, add DATA2 to projection. Then phone type can be got from
// cursor using getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE // cursor using getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE)
val projection = arrayOf(ContactsContract.Data.CONTACT_ID, ContactsContract.Data.DISPLAY_NAME, val projection = arrayOf(ContactsContract.Data.CONTACT_ID, ContactsContract.Data.DISPLAY_NAME,
ContactsContract.Data.MIMETYPE, ContactsContract.Data.DATA1, ContactsContract.Data.MIMETYPE, ContactsContract.Data.DATA1,
/* ContactsContract.Data.DATA2 ,*/ ContactsContract.Data.PHOTO_THUMBNAIL_URI, /* ContactsContract.Data.DATA2 ,*/ ContactsContract.Data.PHOTO_THUMBNAIL_URI,
@@ -241,6 +242,7 @@ sealed class Contact {
val urisPart = uriParams.substringAfter("<").substringBefore(">") val urisPart = uriParams.substringAfter("<").substringBefore(">")
val uris = ArrayList(urisPart.split(",")) val uris = ArrayList(urisPart.split(","))
val params = uriParams.substringAfter(">;") val params = uriParams.substringAfter(">;")
val email = Utils.paramValue(params, "email")
val colorValue = Utils.paramValue(params, "color" ) val colorValue = Utils.paramValue(params, "color" )
val color: Int = if (colorValue != "") val color: Int = if (colorValue != "")
colorValue.toInt() colorValue.toInt()
@@ -253,7 +255,7 @@ sealed class Contact {
baseId + contactNo baseId + contactNo
val favorite = Utils.paramValue(params, "favorite" ) == "yes" val favorite = Utils.paramValue(params, "favorite" ) == "yes"
Log.d(TAG, "Restoring contact $name, $urisPart, $color, $id") Log.d(TAG, "Restoring contact $name, $urisPart, $color, $id")
val contact = BaresipContact(name, uris, color, id, favorite) val contact = BaresipContact(name, uris, email, color, id, favorite)
val avatarFilePath = BaresipService.filesPath + "/$id.png" val avatarFilePath = BaresipService.filesPath + "/$id.png"
if (File(avatarFilePath).exists()) { if (File(avatarFilePath).exists()) {
try { try {
@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.provider.ContactsContract import android.provider.ContactsContract
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -51,6 +52,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -82,6 +84,7 @@ import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import androidx.core.net.toUri
const val avatarSize: Int = 96 const val avatarSize: Int = 96
@@ -115,6 +118,103 @@ private fun ContactsScreen(
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val both = stringResource(R.string.both) val both = stringResource(R.string.both)
val import = stringResource(R.string.import_contacts)
val vcfImportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: android.net.Uri? ->
if (uri != null) {
try {
ctx.contentResolver.openInputStream(uri)?.use { inputStream ->
val reader = inputStream.bufferedReader()
var name = ""
var email = ""
val uris = mutableListOf<String>()
reader.forEachLine { line ->
when {
line.startsWith("BEGIN:VCARD", ignoreCase = true) -> {
name = ""
email = ""
uris.clear()
}
line.startsWith("FN:", ignoreCase = true) -> {
name = line.substring(3).trim()
}
line.startsWith("N:", ignoreCase = true) && name.isEmpty() -> {
name = line.substring(2).trim().replace(";", " ").trim()
}
line.startsWith("EMAIL", ignoreCase = true) -> {
if (email.isEmpty())
email = line.substringAfter(":").trim()
}
line.startsWith("TEL", ignoreCase = true) -> {
val value = line.substringAfter(":").trim()
val cleanValue = value.filterNot { setOf('-', ' ', '(', ')').contains(it) }
if (cleanValue.isNotEmpty()) {
val telUri = if (cleanValue.startsWith("tel:")) cleanValue else "tel:$cleanValue"
if (telUri !in uris) uris.add(telUri)
}
}
line.startsWith("X-SIP:", ignoreCase = true) -> {
val sipUri = line.substring(6).trim()
if (sipUri.isNotEmpty()) {
val fullSipUri = if (sipUri.startsWith("sip:")) sipUri else "sip:$sipUri"
if (fullSipUri !in uris) uris.add(fullSipUri)
}
}
line.startsWith("END:VCARD", ignoreCase = true) -> {
if (name.isNotEmpty() && uris.isNotEmpty()) {
val existingContact = Contact.baresipContact(name)
if (existingContact != null) {
var updated = false
for (u in uris) {
if (u !in existingContact.uris) {
existingContact.uris.add(u)
updated = true
}
}
if (existingContact.email.isEmpty() && email.isNotEmpty()) {
existingContact.email = email
updated = true
}
if (updated) {
Contact.updateBaresipContact(existingContact.id, existingContact)
}
} else {
Contact.addBaresipContact(
Contact.BaresipContact(
name,
ArrayList(uris),
email,
Utils.randomColor(),
System.currentTimeMillis(),
false
)
)
}
}
}
}
}
Contact.saveBaresipContacts()
Contact.contactsUpdate()
Toast.makeText(
ctx,
R.string.contact_import_success,
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to import VCF: ${e.message}")
Toast.makeText(
ctx,
R.string.contact_import_failure,
Toast.LENGTH_SHORT
).show()
}
}
}
val contactNames = remember(BaresipService.contactsMode) { val contactNames = remember(BaresipService.contactsMode) {
val names = mutableListOf("baresip", "Android", both) val names = mutableListOf("baresip", "Android", both)
val values = listOf("baresip", "android", "both") val values = listOf("baresip", "android", "both")
@@ -264,9 +364,13 @@ private fun ContactsScreen(
CustomElements.DropdownMenu( CustomElements.DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
items = contactNames, items = contactNames + import,
onItemClick = { name -> onItemClick = { name ->
expanded = false expanded = false
if (name == import) {
vcfImportLauncher.launch(arrayOf("text/vcard", "text/x-vcard"))
return@DropdownMenu
}
val mode = contactValues[contactNames.indexOf(name)] val mode = contactValues[contactNames.indexOf(name)]
val contactsPermissions = arrayOf( val contactsPermissions = arrayOf(
Manifest.permission.READ_CONTACTS, Manifest.permission.READ_CONTACTS,
@@ -404,6 +508,8 @@ private fun ContactsContent(
val secondAction = remember { mutableStateOf({}) } val secondAction = remember { mutableStateOf({}) }
val lastText = remember { mutableStateOf("") } val lastText = remember { mutableStateOf("") }
val lastAction = remember { mutableStateOf({}) } val lastAction = remember { mutableStateOf({}) }
val thirdText = remember { mutableStateOf("") }
val thirdAction = remember { mutableStateOf({}) }
if (showDialog.value) if (showDialog.value)
AlertDialog( AlertDialog(
@@ -413,6 +519,8 @@ private fun ContactsContent(
firstButtonText = stringResource(R.string.cancel), firstButtonText = stringResource(R.string.cancel),
secondButtonText = secondText.value, secondButtonText = secondText.value,
onSecondClicked = secondAction.value, onSecondClicked = secondAction.value,
thirdButtonText = thirdText.value,
onThirdClicked = thirdAction.value,
lastButtonText = lastText.value, lastButtonText = lastText.value,
onLastClicked = lastAction.value, onLastClicked = lastAction.value,
) )
@@ -430,10 +538,17 @@ private fun ContactsContent(
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val scrollToContact = navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<String>("scrollToContact")
?.observeAsState()
var lastSearchQuery by remember { mutableStateOf("") }
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
if (searchQuery.isBlank()) { if (searchQuery.isBlank() && lastSearchQuery.isNotBlank()) {
lazyListState.scrollToItem(0) lazyListState.scrollToItem(0)
} }
lastSearchQuery = searchQuery
} }
val filteredContacts = remember(BaresipService.contacts, searchQuery) { val filteredContacts = remember(BaresipService.contacts, searchQuery) {
@@ -453,6 +568,16 @@ private fun ContactsContent(
} }
} }
LaunchedEffect(scrollToContact?.value, filteredContacts) {
scrollToContact?.value?.let { name ->
val index = filteredContacts.indexOfFirst { it.first.name() == name }
if (index != -1) {
lazyListState.scrollToItem(index)
}
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("scrollToContact")
}
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -521,9 +646,16 @@ private fun ContactsContent(
} }
else else
Log.w(TAG, "onClickListener did not find UA for $aor") Log.w(TAG, "onClickListener did not find UA for $aor")
val peerName = contact.name()
if (contact.email.isNotEmpty())
dialogMessage.value = String.format(
ctx.getString(R.string.contact_email_action_question),
peerName
)
else
dialogMessage.value = String.format( dialogMessage.value = String.format(
ctx.getString(R.string.contact_action_question), ctx.getString(R.string.contact_action_question),
contact.name() peerName
) )
secondText.value = ctx.getString(R.string.call) secondText.value = ctx.getString(R.string.call)
secondAction.value = { secondAction.value = {
@@ -555,8 +687,8 @@ private fun ContactsContent(
} }
} }
} }
lastText.value = ctx.getString(R.string.send_message) thirdText.value = ctx.getString(R.string.send_message)
lastAction.value = { thirdAction.value = {
if (ua != null) { if (ua != null) {
val uris = if (ua.account.isMobile) val uris = if (ua.account.isMobile)
contact.uris.filter { it.startsWith("tel:") } contact.uris.filter { it.startsWith("tel:") }
@@ -603,6 +735,21 @@ private fun ContactsContent(
} }
} }
} }
if (contact.email.isNotEmpty()) {
lastText.value = ctx.getString(R.string.send_email)
lastAction.value = {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:${contact.email}".toUri()
}
try {
ctx.startActivity(emailIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start email activity: ${e.message}")
}
}
} else {
lastText.value = ""
}
showDialog.value = true showDialog.value = true
}, },
onLongClick = { onLongClick = {
@@ -304,11 +304,12 @@ object CustomElements {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (lastButtonText.isNotEmpty()) { val buttons = listOf(
val buttonCount = listOf(
firstButtonText, secondButtonText, thirdButtonText, lastButtonText firstButtonText, secondButtonText, thirdButtonText, lastButtonText
).count { it.isNotEmpty() } )
val buttonCount = buttons.count { it.isNotEmpty() }
if (buttonCount > 0) {
if (buttonCount >= 3) { if (buttonCount >= 3) {
// Use a Column for 3-4 buttons, aligned to the end (right) // Use a Column for 3-4 buttons, aligned to the end (right)
@@ -316,6 +317,7 @@ object CustomElements {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.End horizontalAlignment = Alignment.End
) { ) {
if (firstButtonText.isNotEmpty())
TextButton(onClick = { TextButton(onClick = {
onFirstClicked() onFirstClicked()
showDialog.value = false showDialog.value = false
@@ -326,6 +328,7 @@ object CustomElements {
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (secondButtonText.isNotEmpty())
TextButton(onClick = { TextButton(onClick = {
onSecondClicked() onSecondClicked()
showDialog.value = false showDialog.value = false
@@ -347,6 +350,7 @@ object CustomElements {
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
if (lastButtonText.isNotEmpty())
TextButton(onClick = { TextButton(onClick = {
onLastClicked() onLastClicked()
showDialog.value = false showDialog.value = false
@@ -386,6 +390,18 @@ object CustomElements {
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
if (thirdButtonText.isNotEmpty())
TextButton(onClick = {
onThirdClicked()
showDialog.value = false
}) {
Text(
text = thirdButtonText.uppercase(),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary
)
}
if (lastButtonText.isNotEmpty())
TextButton(onClick = { TextButton(onClick = {
onLastClicked() onLastClicked()
showDialog.value = false showDialog.value = false
@@ -1406,13 +1406,19 @@ object Utils {
fun sendSms(ctx: Context, destination: String, message: String): Boolean { fun sendSms(ctx: Context, destination: String, message: String): Boolean {
return try { return try {
val smsManager = if (Build.VERSION.SDK_INT >= 31) { val smsManager = if (Build.VERSION.SDK_INT >= 31)
ctx.getSystemService(android.telephony.SmsManager::class.java) ctx.getSystemService(android.telephony.SmsManager::class.java)
} else { else
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
android.telephony.SmsManager.getDefault() android.telephony.SmsManager.getDefault()
} smsManager.sendTextMessage(
smsManager.sendTextMessage(destination, null, message, null, null) destination,
null,
message,
null,
null
)
Log.d(TAG, "Sent SMS message to $destination")
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send SMS: ${e.message}") Log.e(TAG, "Failed to send SMS: ${e.message}")
+12 -3
View File
@@ -483,21 +483,30 @@
<!-- Contact Activity --> <!-- Contact Activity -->
<string name="new_contact">Uusi yhteystieto</string> <string name="new_contact">Uusi yhteystieto</string>
<string name="contact_name">Nimi</string> <string name="contact_name">Nimi</string>
<string name="email">Sähköposti</string>
<string name="sip_or_tel_uri">SIP tai tel URI</string> <string name="sip_or_tel_uri">SIP tai tel URI</string>
<string name="user_domain_or_number">käyttäjä@domain tai puhelinnumero</string> <string name="user_domain_or_number">käyttäjä@domain tai puhelinnumero</string>
<string name="favorite">Suosikki</string> <string name="favorite">Suosikki</string>
<string name="favorite_help">Jos ruksattu, kontakti näytetään muiden suosikkien kanssa yhteystietolistan alussa.</string> <string name="favorite_help">Jos ruksattu, kontakti näytetään muiden suosikkien kanssa
yhteystietolistan alussa.</string>
<string name="invalid_contact">Virheellinen yhteystiedon nimi \'%1$s\'</string> <string name="invalid_contact">Virheellinen yhteystiedon nimi \'%1$s\'</string>
<string name="contact_already_exists">Yhteystieto \'%1$s\' on jo olemassa.</string> <string name="contact_already_exists">Yhteystieto \'%1$s\' on jo olemassa.</string>
<string name="android_contact_help">Jos merkitty, tämä yhteystieto lisätään Androidin yhteystietoihin.</string> <string name="android_contact_help">Jos merkitty, tämä yhteystieto lisätään Androidin yhteystietoihin.</string>
<string name="avatar_image">Profiilikuva</string> <string name="avatar_image">Profiilikuva</string>
<!-- Contacts Activity --> <!-- Contacts Activity -->
<string name="contacts">Yhteystiedot</string> <string name="contacts">Yhteystiedot</string>
<string name="contact_action_question">Haluatko soittaa tai lähettää <string name="contact_action_question">Haluatko soittaa tai lähettää viestin
viestin ositteeseen \'%1$s\'\?</string> osoitteeseen \'%1$s\'\?</string>
<string name="contact_email_action_question">Haluatko soittaa tai lähettää viestin tai sähköpostin
osoitteeseen \'%1$s\'\?</string>
<string name="send_message">Lähetä viesti</string> <string name="send_message">Lähetä viesti</string>
<string name="send_email">Lähetä sähköposti</string>
<string name="contact_delete_question">Haluatko poistaa yhteystiedon \'%1$s\'\?</string> <string name="contact_delete_question">Haluatko poistaa yhteystiedon \'%1$s\'\?</string>
<string name="search">Etsi</string> <string name="search">Etsi</string>
<string name="import_contacts">Tuo</string>
<string name="contact_import_success">Yhteystietojen tuonti onnistui</string>
<string name="contact_import_failure">Yhteystietojen tuonti epäonnistui</string>
<!-- Generic --> <!-- Generic -->
<string name="alert">Varoitus</string> <string name="alert">Varoitus</string>
<string name="info">Tieto</string> <string name="info">Tieto</string>
+6
View File
@@ -456,6 +456,7 @@
<!-- Contact Activity --> <!-- Contact Activity -->
<string name="new_contact">New Contact</string> <string name="new_contact">New Contact</string>
<string name="contact_name">Name</string> <string name="contact_name">Name</string>
<string name="email">Email</string>
<string name="sip_or_tel_uri">SIP or tel URI</string> <string name="sip_or_tel_uri">SIP or tel URI</string>
<string name="user_domain_or_number">user@domain or telephone number</string> <string name="user_domain_or_number">user@domain or telephone number</string>
<string name="favorite">Favorite</string> <string name="favorite">Favorite</string>
@@ -468,9 +469,14 @@
<!-- Contacts Activity --> <!-- Contacts Activity -->
<string name="contacts">Contacts</string> <string name="contacts">Contacts</string>
<string name="contact_action_question">Do you want to call or send message to \'%1$s\'\?</string> <string name="contact_action_question">Do you want to call or send message to \'%1$s\'\?</string>
<string name="contact_email_action_question">Do you want to call, send message, or email to \'%1$s\'\?</string>
<string name="send_message">Send Message</string> <string name="send_message">Send Message</string>
<string name="send_email">Send Email</string>
<string name="contact_delete_question">Do you want to delete contact \'%1$s\'\?</string> <string name="contact_delete_question">Do you want to delete contact \'%1$s\'\?</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="import_contacts">Import</string>
<string name="contact_import_success">Contacts imported successfully</string>
<string name="contact_import_failure">Failed to import contacts</string>
<!-- Generic --> <!-- Generic -->
<string name="alert">Alert</string> <string name="alert">Alert</string>
<string name="info">Info</string> <string name="info">Info</string>