Added possibility to add labels to contacts

This commit is contained in:
Juha Heinanen
2026-05-31 17:10:35 +03:00
parent 908c38a1a5
commit be65d99ac1
5 changed files with 181 additions and 76 deletions

View File

@ -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<String>
abstract fun uris(): List<ContactUri>
abstract fun email(): String
class BaresipContact(var name: String, val uris: ArrayList<String>, var email: String, var color: Int,
class BaresipContact(var name: String, val uris: ArrayList<ContactUri>, 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<String>, var email: String, var color: Int,
class AndroidContact(var name: String, val uris: ArrayList<ContactUri>, 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<String>(), "", Utils.randomColor(), thumb, id, starred == 1)
AndroidContact(name, ArrayList<ContactUri>(), "", 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<ContactUri>()
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" )

View File

@ -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<String> = emptyList(),
val uris: List<Contact.ContactUri> = 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<String>,
uris: List<Contact.ContactUri>,
isEditing: Boolean,
onUrisChange: (List<String>) -> Unit
onUrisChange: (List<Contact.ContactUri>) -> 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<String>()
for (uri in currentState.uris) {
var u = uri.filterNot { setOf('-', ' ', '(', ')').contains(it) }
val newUris = ArrayList<Contact.ContactUri>()
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<ContentProviderOperation>()
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<ContentProviderOperation>()
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)

View File

@ -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<String>()
val uris = mutableListOf<Contact.ContactUri>()
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)
}
}

View File

@ -485,6 +485,7 @@
<!-- Contact Activity -->
<string name="new_contact">Uusi yhteystieto</string>
<string name="contact_name">Nimi</string>
<string name="label">Kuvaus</string>
<string name="email">Sähköposti</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>

View File

@ -460,6 +460,7 @@
<!-- Contact Activity -->
<string name="new_contact">New Contact</string>
<string name="contact_name">Name</string>
<string name="label">Label</string>
<string name="email">Email</string>
<string name="sip_or_tel_uri">SIP or tel URI</string>
<string name="user_domain_or_number">user@domain or telephone number</string>