diff --git a/app/src/main/kotlin/com/tutpro/baresip/Contact.kt b/app/src/main/kotlin/com/tutpro/baresip/Contact.kt index b3c83bcd..866ac52f 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Contact.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Contact.kt @@ -13,14 +13,16 @@ import java.util.ArrayList sealed class Contact { + data class ContactUri(var uri: String, var label: String = "") + abstract fun name(): String abstract fun id(): Long abstract fun favorite(): Boolean abstract fun colorInt(): Int - abstract fun uris(): List + abstract fun uris(): List abstract fun email(): String - class BaresipContact(var name: String, val uris: ArrayList, var email: String, var color: Int, + 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 override fun name() = name @@ -31,7 +33,7 @@ sealed class Contact { override fun email() = email } - class AndroidContact(var name: String, val uris: ArrayList, var email: String, var color: Int, + class AndroidContact(var name: String, val uris: ArrayList, var email: String, var color: Int, var thumbnailUri: Uri?, var id: Long, var favorite: Boolean): Contact() { override fun name() = name override fun id() = id @@ -101,9 +103,9 @@ sealed class Contact { is BaresipContact -> { if (c.name.equals(name, ignoreCase = true)) { for (u in c.uris) { - if (tel && !u.startsWith("tel:")) + if (tel && !u.uri.startsWith("tel:")) continue - uris.add(u) + uris.add(u.uri) } return uris } @@ -111,9 +113,9 @@ sealed class Contact { is AndroidContact -> { if (c.name == name) { for (u in c.uris) { - if (tel && !u.startsWith("tel:")) + if (tel && !u.uri.startsWith("tel:")) continue - uris.add(u) + uris.add(u.uri) } return uris } @@ -129,14 +131,14 @@ sealed class Contact { when (c) { is BaresipContact -> { for (u in c.uris) - if (Utils.uriMatch(u, uri)) + if (Utils.uriMatch(u.uri, uri)) return c } is AndroidContact -> { val cleanUri = uri.filterNot { setOf('-', ' ', '(', ')').contains(it) } for (u in c.uris) if (Utils.uriMatch( - u.filterNot { setOf('-', ' ', '(', ')').contains(it) }, + u.uri.filterNot { setOf('-', ' ', '(', ')').contains(it) }, cleanUri ) ) @@ -161,7 +163,10 @@ sealed class Contact { BaresipService.baresipContacts.value.toList() } for (c in contacts) { - contents += "\"${c.name}\" <${c.uris.joinToString(",")}>;email=${c.email};id=${c.id}" + + val uris = c.uris.joinToString(",") { + if (it.label.isNotEmpty()) "${it.uri}[${it.label}]" else it.uri + } + contents += "\"${c.name}\" <$uris>;email=${c.email};id=${c.id}" + ";color=${c.color};favorite=${if (c.favorite) "yes" else "no"}\n" avatarFiles.remove(c.id.toString() + ".png") } @@ -176,7 +181,7 @@ sealed class Contact { // 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, + ContactsContract.Data.DATA2, ContactsContract.Data.PHOTO_THUMBNAIL_URI, ContactsContract.Contacts.STARRED) val selection = ContactsContract.Data.MIMETYPE + "='" + ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE + "' OR " + @@ -191,12 +196,13 @@ sealed class Contact { val name = cur.getString(1) ?: "" val mime = cur.getString(2) val data = cur.getString(3) ?: continue - val thumb = cur.getString(4)?.toUri() - val starred = cur.getInt(5) + val type = cur.getInt(4) + val thumb = cur.getString(5)?.toUri() + val starred = cur.getInt(6) val contact = if (contacts.containsKey(id)) contacts[id]!! else - AndroidContact(name, ArrayList(), "", Utils.randomColor(), thumb, id, starred == 1) + AndroidContact(name, ArrayList(), "", Utils.randomColor(), thumb, id, starred == 1) if (contact.name == "" && name != "") contact.name = name if (contact.thumbnailUri == null && thumb != null) @@ -204,13 +210,14 @@ sealed class Contact { when (mime) { ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> { val uri = "tel:${data.filterNot { setOf('-', ' ', '(', ')').contains(it) }}" - if (uri !in contact.uris) - contact.uris.add(uri) + val label = typeToString(type) + if (contact.uris.none { it.uri == uri }) + contact.uris.add(ContactUri(uri, label)) } ContactsContract.CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE -> { val uri = "sip:$data" - if (uri !in contact.uris) - contact.uris.add(uri) + if (contact.uris.none { it.uri == uri }) + contact.uris.add(ContactUri(uri, "")) } ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> { if (contact.email == "") @@ -228,13 +235,22 @@ sealed class Contact { BaresipService.androidContacts.value = newList.toList() } - @Suppress("unused") private fun typeToString(type: Int): String { return when(type) { ContactsContract.CommonDataKinds.Phone.TYPE_HOME -> "Home" ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE -> "Mobile" ContactsContract.CommonDataKinds.Phone.TYPE_WORK -> "Work" - else -> "Unknown" + else -> "" + } + } + + fun stringToType(label: String): Int { + return when (label) { + "Home" -> ContactsContract.CommonDataKinds.Phone.TYPE_HOME + "Mobile" -> ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE + "Work" -> ContactsContract.CommonDataKinds.Phone.TYPE_WORK + "" -> ContactsContract.CommonDataKinds.Phone.TYPE_OTHER + else -> ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM } } @@ -252,7 +268,17 @@ sealed class Contact { val name = parts[1] val uriParams = parts[2].trim() val urisPart = uriParams.substringAfter("<").substringBefore(">") - val uris = ArrayList(urisPart.split(",")) + val uris = ArrayList() + for (uPart in urisPart.split(",")) { + if (uPart.isEmpty()) continue + if (uPart.contains("[") && uPart.contains("]")) { + val uri = uPart.substringBefore("[") + val label = uPart.substringAfter("[").substringBefore("]") + uris.add(ContactUri(uri, label)) + } else { + uris.add(ContactUri(uPart, "")) + } + } val params = uriParams.substringAfter(">;") val email = Utils.paramValue(params, "email") val colorValue = Utils.paramValue(params, "color" ) diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt index 5eab5bce..dda83a8c 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme 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 @@ -124,7 +125,7 @@ private data class ScreenState( val id: Long = 0, val newId: Long = 0, val name: String = "", - val uris: List = emptyList(), + val uris: List = emptyList(), val email: String = "", val color: Int = 0, val avatarImageUri: String? = null, @@ -157,7 +158,7 @@ private fun ContactScreen( new = true, isEditing = true, name = "", - uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg), + uris = if (uriOrNameArg == "") emptyList() else listOf(Contact.ContactUri(uriOrNameArg, "")), email = "", favorite = false, android = BaresipService.contactsMode == "android", @@ -606,9 +607,9 @@ private fun UrisSection( ctx: Context, viewModel: ViewModel, navController: NavController, - uris: List, + uris: List, isEditing: Boolean, - onUrisChange: (List) -> Unit + onUrisChange: (List) -> Unit ) { val selectedAor by viewModel.selectedAor.collectAsState() @@ -617,24 +618,39 @@ private fun UrisSection( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - uris.forEachIndexed { index, uri -> + uris.forEachIndexed { index, contactUri -> Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - value = uri, - placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, - onValueChange = { newUri -> - val newList = uris.toMutableList() - newList[index] = newUri - onUrisChange(newList.toList()) - }, - modifier = Modifier.weight(1f), - textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), - label = { Text("${stringResource(R.string.sip_or_tel_uri)} ${index + 1}") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) + Column (modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = contactUri.uri, + placeholder = { Text(stringResource(R.string.user_domain_or_number)) }, + onValueChange = { newUri -> + val newList = uris.toMutableList() + newList[index] = contactUri.copy(uri = newUri) + onUrisChange(newList.toList()) + }, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + label = { Text("${stringResource(R.string.sip_or_tel_uri)} ${index + 1}") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + OutlinedTextField( + value = contactUri.label, + placeholder = { Text(stringResource(R.string.label)) }, + onValueChange = { newLabel -> + val newList = uris.toMutableList() + newList[index] = contactUri.copy(label = newLabel) + onUrisChange(newList.toList()) + }, + modifier = Modifier.fillMaxWidth(), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + label = { Text(stringResource(R.string.label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + } if (uris.size > 1) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { IconButton( @@ -659,7 +675,7 @@ private fun UrisSection( IconButton( onClick = { val newList = uris.toMutableList() - newList.add("") + newList.add(Contact.ContactUri("", "")) onUrisChange(newList.toList()) }, modifier = Modifier.align(Alignment.Start) @@ -674,15 +690,32 @@ private fun UrisSection( } else { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (uri in uris) { + uris.forEachIndexed { index, contactUri -> Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.bullet_item, uri.substringAfter(":")), + val uri = contactUri.uri + val label = contactUri.label.ifEmpty { + if (uri.startsWith("tel:")) + "${stringResource(R.string.tel_uri)} ${index + 1}" + else + "${stringResource(R.string.sip_uri)} ${index + 1}" + } + + OutlinedTextField( + value = uri.substringAfter(":"), + onValueChange = {}, + enabled = false, modifier = Modifier.weight(1f), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onBackground, + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + label = { Text(text = label, fontWeight = FontWeight.Bold) }, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) ) val ua = UserAgent.ofAor(selectedAor) @@ -772,15 +805,24 @@ private fun EmailSection(ctx: Context, email: String, isEditing: Boolean, onEmai ) } else if (email.isNotEmpty()) { Row( - Modifier.fillMaxWidth().padding(bottom = 12.dp), + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { - Text( - text = stringResource(R.string.bullet_item, email), + OutlinedTextField( + value = email, + onValueChange = {}, + enabled = false, modifier = Modifier.weight(1f), - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onBackground, + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp), + label = { Text(text = stringResource(R.string.email), fontWeight = FontWeight.Bold) }, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) ) IconButton( onClick = { @@ -855,9 +897,9 @@ private fun checkOnClick( currentState: ScreenState, uriOrNameArg: String ): Boolean { - val newUris = ArrayList() - for (uri in currentState.uris) { - var u = uri.filterNot { setOf('-', ' ', '(', ')').contains(it) } + val newUris = ArrayList() + for (contactUri in currentState.uris) { + var u = contactUri.uri.filterNot { setOf('-', ' ', '(', ')').contains(it) } if (u == "") continue if (!u.startsWith("sip:") && !u.startsWith("tel:")) u = if (Utils.isTelNumber(u)) "tel:$u" else "sip:$u" @@ -868,7 +910,7 @@ private fun checkOnClick( showAlert.value = true return false } - newUris.add(u) + newUris.add(Contact.ContactUri(u, contactUri.label)) } if (newUris.isEmpty() && currentState.email.trim().isEmpty()) { @@ -879,7 +921,7 @@ private fun checkOnClick( } var newName = currentState.name.trim() - if (newName == "") newName = if (newUris.isNotEmpty()) newUris[0].substringAfter(":") else currentState.email + if (newName == "") newName = if (newUris.isNotEmpty()) newUris[0].uri.substringAfter(":") else currentState.email if (!Utils.checkName(newName)) { alertTitle.value = ctx.getString(R.string.notice) alertMessage.value = String.format(ctx.getString(R.string.invalid_contact), newName) @@ -1015,12 +1057,21 @@ private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Bo .withValue(CommonDataKinds.Email.ADDRESS, contact.email) .withValue(CommonDataKinds.Email.TYPE, CommonDataKinds.Email.TYPE_HOME).build()) } - for (uri in contact.uris) { + for (contactUri in contact.uris) { + val uri = contactUri.uri + val label = contactUri.label val mimeType = if (uri.startsWith("sip:")) CommonDataKinds.SipAddress.CONTENT_ITEM_TYPE else CommonDataKinds.Phone.CONTENT_ITEM_TYPE - ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + val builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) .withValueBackReference(Data.RAW_CONTACT_ID, 0) .withValue(Data.MIMETYPE, mimeType) - .withValue(Data.DATA1, uri.substringAfter(":")).build()) + .withValue(Data.DATA1, uri.substringAfter(":")) + if (mimeType == CommonDataKinds.Phone.CONTENT_ITEM_TYPE) { + val type = Contact.stringToType(label) + builder.withValue(CommonDataKinds.Phone.TYPE, type) + if (type == CommonDataKinds.Phone.TYPE_CUSTOM) + builder.withValue(CommonDataKinds.Phone.LABEL, label) + } + ops.add(builder.build()) } if (contact.avatarImage != null) { val photoData = bitmapToPNGByteArray(contact.avatarImage!!) @@ -1044,17 +1095,26 @@ private fun updateAndroidContact(ctx: Context, rawContactId: Long, contact: Cont 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) + for (contactUri in contact.uris) if (updateAndroidUri(ctx, rawContactId, contactUri) == 0) addAndroidUri(ctx, rawContactId, contactUri) 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) { +private fun addAndroidUri(ctx: Context, rawContactId: Long, contactUri: Contact.ContactUri) { + val uri = contactUri.uri + val label = contactUri.label 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) + val builder = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) .withValue(Data.RAW_CONTACT_ID, rawContactId) .withValue(Data.MIMETYPE, mimeType) - .withValue(Data.DATA1, uri.substringAfter(":")).build()) + .withValue(Data.DATA1, uri.substringAfter(":")) + if (mimeType == CommonDataKinds.Phone.CONTENT_ITEM_TYPE) { + val type = Contact.stringToType(label) + builder.withValue(CommonDataKinds.Phone.TYPE, type) + if (type == CommonDataKinds.Phone.TYPE_CUSTOM) + builder.withValue(CommonDataKinds.Phone.LABEL, label) + } + val ops = ArrayList() + ops.add(builder.build()) try { ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) } catch (e: Exception) { @@ -1062,10 +1122,18 @@ private fun addAndroidUri(ctx: Context, rawContactId: Long, uri: String) { } } -private fun updateAndroidUri(ctx: Context, rawContactId: Long, uri: String): Int { +private fun updateAndroidUri(ctx: Context, rawContactId: Long, contactUri: Contact.ContactUri): Int { + val uri = contactUri.uri + val label = contactUri.label 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) + contentValues.put(ContactsContract.Data.DATA1, uri.substringAfter(":")) + if (mimeType == CommonDataKinds.Phone.CONTENT_ITEM_TYPE) { + val type = Contact.stringToType(label) + contentValues.put(CommonDataKinds.Phone.TYPE, type) + if (type == CommonDataKinds.Phone.TYPE_CUSTOM) + contentValues.put(CommonDataKinds.Phone.LABEL, label) + } 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) diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt index f7977c23..79e9db59 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt @@ -151,10 +151,17 @@ private fun ContactsScreen(navController: NavController) { writer.write("PHOTO;ENCODING=BASE64;JPEG:$base64Image\n") } for (u in contact.uris) { - if (u.startsWith("tel:")) - writer.write("TEL:${u.substring(4)}\n") - else if (u.startsWith("sip:")) - writer.write("X-SIP:${u.substring(4)}\n") + if (u.uri.startsWith("tel:")) { + if (u.label.isNotEmpty()) + writer.write("TEL;X-${u.label}:${u.uri.substring(4)}\n") + else + writer.write("TEL:${u.uri.substring(4)}\n") + } else if (u.uri.startsWith("sip:")) { + if (u.label.isNotEmpty()) + writer.write("X-SIP;X-${u.label}:${u.uri.substring(4)}\n") + else + writer.write("X-SIP:${u.uri.substring(4)}\n") + } } writer.write("END:VCARD\n") } @@ -188,7 +195,7 @@ private fun ContactsScreen(navController: NavController) { var name = "" var email = "" - val uris = mutableListOf() + val uris = mutableListOf() var photoBase64 = "" var contactNo = 0 val newBaresipContacts = BaresipService.baresipContacts.value.toMutableList() @@ -212,18 +219,20 @@ private fun ContactsScreen(navController: NavController) { email = line.substringAfter(":").trim() } line.startsWith("TEL", ignoreCase = true) -> { + val label = if (line.contains("X-")) line.substringAfter("X-").substringBefore(":") else "" 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) + if (uris.none { it.uri == telUri }) uris.add(Contact.ContactUri(telUri, label)) } } - line.startsWith("X-SIP:", ignoreCase = true) -> { - val sipUri = line.substring(6).trim() + line.startsWith("X-SIP", ignoreCase = true) -> { + val label = if (line.contains("X-")) line.substringAfter("X-").substringBefore(":") else "" + val sipUri = line.substringAfter(":").trim() if (sipUri.isNotEmpty()) { val fullSipUri = if (sipUri.startsWith("sip:")) sipUri else "sip:$sipUri" - if (fullSipUri !in uris) uris.add(fullSipUri) + if (uris.none { it.uri == fullSipUri }) uris.add(Contact.ContactUri(fullSipUri, label)) } } line.startsWith("PHOTO", ignoreCase = true) && line.contains("BASE64", ignoreCase = true) -> { @@ -249,7 +258,7 @@ private fun ContactsScreen(navController: NavController) { } if (existingContact != null) { for (u in uris) { - if (u !in existingContact.uris) { + if (existingContact.uris.none { it.uri == u.uri }) { existingContact.uris.add(u) } } diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 536e2cea..6cbe9e01 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -485,6 +485,7 @@ Uusi yhteystieto Nimi + Kuvaus Sähköposti SIP tai TEL URI käyttäjä@domain tai puhelinnumero diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32385bd9..b0cc8218 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -460,6 +460,7 @@ New Contact Name + Label Email SIP or tel URI user@domain or telephone number