From 8f95b3609c575e785970a14d2fac38ade7b9b91a Mon Sep 17 00:00:00 2001 From: Juha Heinanen Date: Mon, 18 May 2026 12:04:52 +0300 Subject: [PATCH] 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 --- .../tutpro/baresip/BaresipContactScreen.kt | 81 ++++++++- .../kotlin/com/tutpro/baresip/CallsScreen.kt | 38 +++- .../kotlin/com/tutpro/baresip/ChatScreen.kt | 39 ++++- .../kotlin/com/tutpro/baresip/ChatsScreen.kt | 24 +-- .../main/kotlin/com/tutpro/baresip/Contact.kt | 28 +-- .../com/tutpro/baresip/ContactsScreen.kt | 163 +++++++++++++++++- .../com/tutpro/baresip/CustomElements.kt | 104 ++++++----- .../main/kotlin/com/tutpro/baresip/Utils.kt | 14 +- app/src/main/res/values-fi/strings.xml | 15 +- app/src/main/res/values/strings.xml | 6 + 10 files changed, 415 insertions(+), 97 deletions(-) diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt index 5af359ab..3360d1ae 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipContactScreen.kt @@ -33,13 +33,15 @@ 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.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.Add 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.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -111,6 +113,7 @@ private data class ScreenState( val newId: Long = 0, val name: String = "", val uris: List = emptyList(), + val email: String = "", val color: Int = 0, val avatarImageUri: String? = null, val tmpAvatarFile: File? = null, @@ -141,6 +144,7 @@ private fun ContactScreen( new = true, name = "", uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg), + email = "", favorite = false, android = BaresipService.contactsMode == "android", color = Utils.randomColor(), @@ -156,6 +160,7 @@ private fun ContactScreen( new = false, name = uriOrNameArg, uris = contact.uris, + email = contact.email, favorite = contact.favorite, android = false, color = contact.color, @@ -186,8 +191,10 @@ private fun ContactScreen( currentState = screenState, uriOrNameArg = uriOrNameArg, ) - if (result) + if (result) { + navController.previousBackStackEntry?.savedStateHandle?.set("scrollToContact", screenState.name) navController.navigateUp() + } } BackHandler(enabled = true) { @@ -279,12 +286,14 @@ private fun ContactContent( ) } + val scrollState = rememberScrollState() Column( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) .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), ) { Avatar( @@ -326,6 +335,10 @@ private fun ContactContent( uris = screenState.uris, onUrisChange = { newUris -> onStateChange(screenState.copy(uris = newUris)) } ) + ContactEmail( + email = screenState.email, + onEmailChange = { newEmail -> onStateChange(screenState.copy(email = newEmail)) } + ) Favorite( ctx = ctx, favorite = screenState.favorite, @@ -491,8 +504,8 @@ private fun ContactUris(uris: List, onUrisChange: (List) -> Unit modifier = Modifier.padding(start = 4.dp) ) { Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete", + imageVector = Icons.Filled.Remove, + contentDescription = "Remove", tint = MaterialTheme.colorScheme.error ) } @@ -516,6 +529,19 @@ private fun ContactUris(uris: List, onUrisChange: (List) -> 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 private fun Favorite(ctx: Context, favorite: Boolean, onFavoriteChange: (Boolean) -> Unit) { Row( @@ -633,6 +659,7 @@ private fun checkOnClick( Contact.BaresipContact( newName, newUris, + currentState.email.trim(), currentState.color, idToUse, currentState.favorite @@ -743,6 +770,16 @@ private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Bo .withValue(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, contact.name) .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) { val mimeType = if (uri.startsWith("sip:")) 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) { + if (contact.email.isNotEmpty()) { + if (updateAndroidEmail(ctx, rawContactId, contact.email) == 0) + addAndroidEmail(ctx, rawContactId, contact.email) + } for (uri in contact.uris) if (updateAndroidUri(ctx, rawContactId, uri) == 0) 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() + 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 { val photoBytes = if (photoBits == null) null diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt index 616875f5..5600adf1 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt @@ -71,6 +71,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument import coil.compose.AsyncImage +import androidx.core.net.toUri import com.tutpro.baresip.CustomElements.AlertDialog import com.tutpro.baresip.CustomElements.SelectableAlertDialog import com.tutpro.baresip.CustomElements.verticalScrollbar @@ -279,6 +280,8 @@ private fun Calls( val message = remember { mutableStateOf("") } val secondButtonText = remember { mutableStateOf("") } val secondAction = remember { mutableStateOf({}) } + val thirdButtonText = remember { mutableStateOf("") } + val thirdAction = remember { mutableStateOf({}) } val lastButtonText = remember { mutableStateOf("") } val lastAction = remember { mutableStateOf({}) } @@ -289,6 +292,8 @@ private fun Calls( firstButtonText = stringResource(R.string.cancel), secondButtonText = secondButtonText.value, onSecondClicked = secondAction.value, + thirdButtonText = thirdButtonText.value, + onThirdClicked = thirdAction.value, lastButtonText = lastButtonText.value, onLastClicked = lastAction.value, ) @@ -345,11 +350,20 @@ private fun Calls( 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) + 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) secondAction.value = { if (ua != null) { - val contact = Contact.findContact(peerUri) val uris = if (contact is Contact.BaresipContact) { if (ua.account.isMobile) contact.uris.filter { it.startsWith("tel:") } @@ -381,10 +395,9 @@ private fun Calls( } } } - lastButtonText.value = ctx.getString(R.string.send_message) - lastAction.value = { + thirdButtonText.value = ctx.getString(R.string.send_message) + thirdAction.value = { if (ua != null) { - val contact = Contact.findContact(peerUri) val uris = if (contact is Contact.BaresipContact) { if (ua.account.isMobile) 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 }, onLongClick = { diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt index 618138be..194663e7 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatScreen.kt @@ -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.outlined.Call import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Email import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.Observer @@ -216,7 +218,6 @@ private fun TopAppBar( peerUri: String ) { val aor = account.aor - TopAppBar( title = { Text( @@ -245,10 +246,10 @@ private fun TopAppBar( 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") + val callIntent = Intent(ctx, MainActivity::class.java) + .putExtra("uap", ua.uap) + .putExtra("peer", peerUri) + handleIntent(ctx, viewModel, callIntent, "call") navController.navigate("main") { popUpTo("main") launchSingleTop = true @@ -263,6 +264,34 @@ private fun TopAppBar( 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", + ) + } } ) } diff --git a/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt index 87fc1b8e..e0971ffa 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ChatsScreen.kt @@ -433,15 +433,19 @@ private fun NewChatPeer(ctx: Context, navController: NavController, account: Acc 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 + if (account.isMobile) + peerUri + else + 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)) { @@ -589,7 +593,7 @@ private fun NewChatPeer(ctx: Context, navController: NavController, account: Acc showSuggestions = false val peerText = newPeer.trim() if (peerText.isNotEmpty()) { - val uris = Contact.contactUris(peerText) + val uris = Contact.contactUris(peerText, account.isMobile) if (uris.isEmpty()) makeChat(ctx, navController, account, peerText) else if (uris.size == 1) diff --git a/app/src/main/kotlin/com/tutpro/baresip/Contact.kt b/app/src/main/kotlin/com/tutpro/baresip/Contact.kt index 4a97063c..91169fd2 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Contact.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Contact.kt @@ -13,8 +13,8 @@ import java.util.ArrayList sealed class Contact { - class BaresipContact(var name: String, val uris: ArrayList, var color: Int, var id: Long, - var favorite: Boolean): Contact() { + class BaresipContact(var name: String, val uris: ArrayList, var email: String, var color: Int, + var id: Long, var favorite: Boolean): Contact() { var avatarImage: Bitmap? = null } @@ -57,7 +57,7 @@ sealed class Contact { fun copy(): Contact { val copy = when (this) { is BaresipContact -> - BaresipContact(name, ArrayList(uris), color, id, favorite) + BaresipContact(name, ArrayList(uris), email, color, id, favorite) is AndroidContact -> AndroidContact(name, color, thumbnailUri, id, favorite) } @@ -101,16 +101,17 @@ sealed class Contact { } // Return URIs of contact name - fun contactUris(name: String): ArrayList { + fun contactUris(name: String, tel: Boolean = false): ArrayList { val uris = ArrayList() for (c in BaresipService.contacts) when (c) { is BaresipContact -> { if (c.name.equals(name, ignoreCase = true)) { - for (u in c.uris) - uris.add(u.removePrefix("<") - .replaceAfter(">", "") - .replace(">", "")) + for (u in c.uris) { + if (tel && !u.startsWith("tel:")) + continue + uris.add(u) + } return uris } } @@ -155,8 +156,8 @@ sealed class Contact { val avatarFiles = avatarFileNames() var contents = "" for (c in BaresipService.baresipContacts.value) { - contents += "\"${c.name}\" <${c.uris.joinToString(",")}>;id=${c.id};color=${c.color}" + - ";favorite=${if (c.favorite) "yes" else "no"}\n" + contents += "\"${c.name}\" <${c.uris.joinToString(",")}>;email=${c.email};id=${c.id}" + + ";color=${c.color};favorite=${if (c.favorite) "yes" else "no"}\n" avatarFiles.remove(c.id.toString() + ".png") } Utils.putFileContents(BaresipService.filesPath + "/contacts", @@ -166,8 +167,8 @@ sealed class Contact { } fun loadAndroidContacts(ctx: Context) { - // If phone type is needed, add DATA2 to projection. Then phone type can be get from - // cursor using getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE + // If phone type is needed, add DATA2 to projection. Then phone type can be got from + // cursor using getColumnIndex(ContactsContract.CommonDataKinds.Phone.TYPE) val projection = arrayOf(ContactsContract.Data.CONTACT_ID, ContactsContract.Data.DISPLAY_NAME, ContactsContract.Data.MIMETYPE, ContactsContract.Data.DATA1, /* ContactsContract.Data.DATA2 ,*/ ContactsContract.Data.PHOTO_THUMBNAIL_URI, @@ -241,6 +242,7 @@ sealed class Contact { val urisPart = uriParams.substringAfter("<").substringBefore(">") val uris = ArrayList(urisPart.split(",")) val params = uriParams.substringAfter(">;") + val email = Utils.paramValue(params, "email") val colorValue = Utils.paramValue(params, "color" ) val color: Int = if (colorValue != "") colorValue.toInt() @@ -253,7 +255,7 @@ sealed class Contact { baseId + contactNo val favorite = Utils.paramValue(params, "favorite" ) == "yes" 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" if (File(avatarFilePath).exists()) { try { diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt index f113889b..ac758f46 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.provider.ContactsContract import android.util.Log +import android.widget.Toast import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -51,6 +52,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -82,6 +84,7 @@ import com.tutpro.baresip.CustomElements.TextAvatar import com.tutpro.baresip.CustomElements.verticalScrollbar import java.io.File import java.io.IOException +import androidx.core.net.toUri const val avatarSize: Int = 96 @@ -115,6 +118,103 @@ private fun ContactsScreen( var expanded by remember { mutableStateOf(false) } 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() + 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 names = mutableListOf("baresip", "Android", both) val values = listOf("baresip", "android", "both") @@ -264,9 +364,13 @@ private fun ContactsScreen( CustomElements.DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - items = contactNames, + items = contactNames + import, onItemClick = { name -> expanded = false + if (name == import) { + vcfImportLauncher.launch(arrayOf("text/vcard", "text/x-vcard")) + return@DropdownMenu + } val mode = contactValues[contactNames.indexOf(name)] val contactsPermissions = arrayOf( Manifest.permission.READ_CONTACTS, @@ -404,6 +508,8 @@ private fun ContactsContent( val secondAction = remember { mutableStateOf({}) } val lastText = remember { mutableStateOf("") } val lastAction = remember { mutableStateOf({}) } + val thirdText = remember { mutableStateOf("") } + val thirdAction = remember { mutableStateOf({}) } if (showDialog.value) AlertDialog( @@ -413,6 +519,8 @@ private fun ContactsContent( firstButtonText = stringResource(R.string.cancel), secondButtonText = secondText.value, onSecondClicked = secondAction.value, + thirdButtonText = thirdText.value, + onThirdClicked = thirdAction.value, lastButtonText = lastText.value, onLastClicked = lastAction.value, ) @@ -430,10 +538,17 @@ private fun ContactsContent( val lazyListState = rememberLazyListState() + val scrollToContact = navController.currentBackStackEntry + ?.savedStateHandle + ?.getLiveData("scrollToContact") + ?.observeAsState() + + var lastSearchQuery by remember { mutableStateOf("") } LaunchedEffect(searchQuery) { - if (searchQuery.isBlank()) { + if (searchQuery.isBlank() && lastSearchQuery.isNotBlank()) { lazyListState.scrollToItem(0) } + lastSearchQuery = 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("scrollToContact") + } + } + LazyColumn( modifier = Modifier .fillMaxWidth() @@ -521,10 +646,17 @@ private fun ContactsContent( } else Log.w(TAG, "onClickListener did not find UA for $aor") - dialogMessage.value = String.format( - ctx.getString(R.string.contact_action_question), - contact.name() - ) + 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( + ctx.getString(R.string.contact_action_question), + peerName + ) secondText.value = ctx.getString(R.string.call) secondAction.value = { if (ua != null) { @@ -555,8 +687,8 @@ private fun ContactsContent( } } } - lastText.value = ctx.getString(R.string.send_message) - lastAction.value = { + thirdText.value = ctx.getString(R.string.send_message) + thirdAction.value = { if (ua != null) { val uris = if (ua.account.isMobile) 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 }, onLongClick = { diff --git a/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt b/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt index e6258352..c74e235d 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CustomElements.kt @@ -304,11 +304,12 @@ object CustomElements { ) Spacer(modifier = Modifier.height(16.dp)) - if (lastButtonText.isNotEmpty()) { + val buttons = listOf( + firstButtonText, secondButtonText, thirdButtonText, lastButtonText + ) + val buttonCount = buttons.count { it.isNotEmpty() } - val buttonCount = listOf( - firstButtonText, secondButtonText, thirdButtonText, lastButtonText - ).count { it.isNotEmpty() } + if (buttonCount > 0) { if (buttonCount >= 3) { // Use a Column for 3-4 buttons, aligned to the end (right) @@ -316,26 +317,28 @@ object CustomElements { modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End ) { - TextButton(onClick = { - onFirstClicked() - showDialog.value = false - }) { - Text( - text = firstButtonText.uppercase(), - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - TextButton(onClick = { - onSecondClicked() - showDialog.value = false - }) { - Text( - text = secondButtonText.uppercase(), - fontSize = 14.sp, - color = MaterialTheme.colorScheme.primary, - ) - } + if (firstButtonText.isNotEmpty()) + TextButton(onClick = { + onFirstClicked() + showDialog.value = false + }) { + Text( + text = firstButtonText.uppercase(), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (secondButtonText.isNotEmpty()) + TextButton(onClick = { + onSecondClicked() + showDialog.value = false + }) { + Text( + text = secondButtonText.uppercase(), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary, + ) + } if (thirdButtonText.isNotEmpty()) TextButton(onClick = { onThirdClicked() @@ -347,16 +350,17 @@ object CustomElements { color = MaterialTheme.colorScheme.primary, ) } - TextButton(onClick = { - onLastClicked() - showDialog.value = false - }) { - Text( - text = lastButtonText.uppercase(), - fontSize = 14.sp, - color = MaterialTheme.colorScheme.primary - ) - } + if (lastButtonText.isNotEmpty()) + TextButton(onClick = { + onLastClicked() + showDialog.value = false + }) { + Text( + text = lastButtonText.uppercase(), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary + ) + } } } else { // Use the existing Row for 1 or 2 buttons @@ -386,16 +390,28 @@ object CustomElements { color = MaterialTheme.colorScheme.primary ) } - TextButton(onClick = { - onLastClicked() - showDialog.value = false - }) { - Text( - text = lastButtonText.uppercase(), - fontSize = 14.sp, - 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 = { + onLastClicked() + showDialog.value = false + }) { + Text( + text = lastButtonText.uppercase(), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary + ) + } } } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 3939fa02..43bea4cb 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -1406,13 +1406,19 @@ object Utils { fun sendSms(ctx: Context, destination: String, message: String): Boolean { 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) - } else { + else @Suppress("DEPRECATION") android.telephony.SmsManager.getDefault() - } - smsManager.sendTextMessage(destination, null, message, null, null) + smsManager.sendTextMessage( + destination, + null, + message, + null, + null + ) + Log.d(TAG, "Sent SMS message to $destination") true } catch (e: Exception) { Log.e(TAG, "Failed to send SMS: ${e.message}") diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b8cf93e8..ecce357b 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -483,21 +483,30 @@ Uusi yhteystieto Nimi + Sähköposti SIP tai tel URI käyttäjä@domain tai puhelinnumero Suosikki - Jos ruksattu, kontakti näytetään muiden suosikkien kanssa yhteystietolistan alussa. + Jos ruksattu, kontakti näytetään muiden suosikkien kanssa + yhteystietolistan alussa. Virheellinen yhteystiedon nimi \'%1$s\' Yhteystieto \'%1$s\' on jo olemassa. Jos merkitty, tämä yhteystieto lisätään Androidin yhteystietoihin. Profiilikuva Yhteystiedot - Haluatko soittaa tai lähettää - viestin ositteeseen \'%1$s\'\? + Haluatko soittaa tai lähettää viestin + osoitteeseen \'%1$s\'\? + Haluatko soittaa tai lähettää viestin tai sähköpostin + osoitteeseen \'%1$s\'\? Lähetä viesti + Lähetä sähköposti Haluatko poistaa yhteystiedon \'%1$s\'\? Etsi + Tuo + Yhteystietojen tuonti onnistui + Yhteystietojen tuonti epäonnistui + Varoitus Tieto diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 553cc64d..bc02cf09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,6 +456,7 @@ New Contact Name + Email SIP or tel URI user@domain or telephone number Favorite @@ -468,9 +469,14 @@ Contacts Do you want to call or send message to \'%1$s\'\? + Do you want to call, send message, or email to \'%1$s\'\? Send Message + Send Email Do you want to delete contact \'%1$s\'\? Search + Import + Contacts imported successfully + Failed to import contacts Alert Info