Added "Delete" action to contacts menu

Import and export of baresip contacts now support also avatar images
This commit is contained in:
Juha Heinanen
2026-05-20 14:52:46 +03:00
parent 06730b4cfa
commit 50806025cb
2 changed files with 123 additions and 48 deletions

View File

@ -4,7 +4,10 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.provider.ContactsContract import android.provider.ContactsContract
import android.util.Base64
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
@ -55,8 +58,6 @@ 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
import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale
import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -73,6 +74,8 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
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.app.ActivityCompat.shouldShowRequestPermissionRationale
import androidx.core.content.ContextCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -81,7 +84,9 @@ import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.SelectableAlertDialog import com.tutpro.baresip.CustomElements.SelectableAlertDialog
import com.tutpro.baresip.CustomElements.TextAvatar import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
const val avatarSize: Int = 96 const val avatarSize: Int = 96
@ -112,6 +117,11 @@ private fun ContactsScreen(navController: NavController) {
val both = stringResource(R.string.both) val both = stringResource(R.string.both)
val import = stringResource(R.string.import_contacts) val import = stringResource(R.string.import_contacts)
val export = stringResource(R.string.export_contacts) val export = stringResource(R.string.export_contacts)
val delete = stringResource(R.string.delete)
val confirmation = stringResource(R.string.confirmation)
val cancel = stringResource(R.string.cancel)
val contactsDeleteQuestion = stringResource(R.string.contacts_delete_question)
val contactDeleteQuestion = stringResource(R.string.contact_delete_question)
val vcfExportLauncher = rememberLauncherForActivityResult( val vcfExportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/vcard") contract = ActivityResultContracts.CreateDocument("text/vcard")
@ -131,6 +141,12 @@ private fun ContactsScreen(navController: NavController) {
writer.write("N:;${contact.name};;;\n") writer.write("N:;${contact.name};;;\n")
if (contact.email.isNotEmpty()) if (contact.email.isNotEmpty())
writer.write("EMAIL:${contact.email}\n") writer.write("EMAIL:${contact.email}\n")
if (contact.avatarImage != null) {
val outputStreamPhoto = ByteArrayOutputStream()
contact.avatarImage!!.compress(Bitmap.CompressFormat.JPEG, 100, outputStreamPhoto)
val base64Image = Base64.encodeToString(outputStreamPhoto.toByteArray(), Base64.NO_WRAP)
writer.write("PHOTO;ENCODING=BASE64;JPEG:$base64Image\n")
}
for (u in contact.uris) { for (u in contact.uris) {
if (u.startsWith("tel:")) if (u.startsWith("tel:"))
writer.write("TEL:${u.substring(4)}\n") writer.write("TEL:${u.substring(4)}\n")
@ -156,15 +172,31 @@ private fun ContactsScreen(navController: NavController) {
try { try {
ctx.contentResolver.openInputStream(uri)?.use { inputStream -> ctx.contentResolver.openInputStream(uri)?.use { inputStream ->
val reader = inputStream.bufferedReader() val reader = inputStream.bufferedReader()
val lines = mutableListOf<String>()
reader.forEachLine { line ->
if (line.startsWith(" ") || line.startsWith("\t")) {
if (lines.isNotEmpty()) {
lines[lines.size - 1] = lines.last() + line.trim()
}
} else {
lines.add(line)
}
}
var name = "" var name = ""
var email = "" var email = ""
val uris = mutableListOf<String>() val uris = mutableListOf<String>()
reader.forEachLine { line -> var photoBase64 = ""
var contactNo = 0
val newBaresipContacts = BaresipService.baresipContacts.value.toMutableList()
for (line in lines) {
when { when {
line.startsWith("BEGIN:VCARD", ignoreCase = true) -> { line.startsWith("BEGIN:VCARD", ignoreCase = true) -> {
name = "" name = ""
email = "" email = ""
uris.clear() uris.clear()
photoBase64 = ""
} }
line.startsWith("FN:", ignoreCase = true) -> { line.startsWith("FN:", ignoreCase = true) -> {
name = line.substring(3).trim() name = line.substring(3).trim()
@ -191,32 +223,44 @@ private fun ContactsScreen(navController: NavController) {
if (fullSipUri !in uris) uris.add(fullSipUri) if (fullSipUri !in uris) uris.add(fullSipUri)
} }
} }
line.startsWith("PHOTO", ignoreCase = true) && line.contains("BASE64", ignoreCase = true) -> {
photoBase64 = line.substringAfter(":").trim()
}
line.startsWith("END:VCARD", ignoreCase = true) -> { line.startsWith("END:VCARD", ignoreCase = true) -> {
if (name.isNotEmpty() && (uris.isNotEmpty() || email.isNotEmpty())) { if (name.isNotEmpty() && (uris.isNotEmpty() || email.isNotEmpty())) {
val existingContact = Contact.baresipContact(name) val existingContact = newBaresipContacts.find { it.name == name }
val contactId = existingContact?.id ?: (System.currentTimeMillis() + contactNo++)
if (photoBase64.isNotEmpty()) {
try {
val decodedString = Base64.decode(photoBase64, Base64.DEFAULT)
val decodedByte = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
if (decodedByte != null) {
val avatarFile = File(BaresipService.filesPath, "$contactId.png")
FileOutputStream(avatarFile).use { out ->
decodedByte.compress(Bitmap.CompressFormat.PNG, 100, out)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decode photo for $name: ${e.message}")
}
}
if (existingContact != null) { if (existingContact != null) {
var updated = false
for (u in uris) { for (u in uris) {
if (u !in existingContact.uris) { if (u !in existingContact.uris) {
existingContact.uris.add(u) existingContact.uris.add(u)
updated = true
} }
} }
if (existingContact.email.isEmpty() && email.isNotEmpty()) { if (existingContact.email.isEmpty() && email.isNotEmpty()) {
existingContact.email = email existingContact.email = email
updated = true
}
if (updated) {
Contact.updateBaresipContact(existingContact.id, existingContact)
} }
} else { } else {
Contact.addBaresipContact( newBaresipContacts.add(
Contact.BaresipContact( Contact.BaresipContact(
name, name,
ArrayList(uris), ArrayList(uris),
email, email,
Utils.randomColor(), Utils.randomColor(),
System.currentTimeMillis(), contactId,
false false
) )
) )
@ -225,7 +269,9 @@ private fun ContactsScreen(navController: NavController) {
} }
} }
} }
BaresipService.baresipContacts.value = newBaresipContacts.toList()
Contact.saveBaresipContacts() Contact.saveBaresipContacts()
Contact.restoreBaresipContacts()
Contact.contactsUpdate() Contact.contactsUpdate()
Toast.makeText( Toast.makeText(
ctx, ctx,
@ -393,7 +439,7 @@ private fun ContactsScreen(navController: NavController) {
CustomElements.DropdownMenu( CustomElements.DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
items = contactNames + import + export, items = contactNames + import + export + delete,
onItemClick = { name -> onItemClick = { name ->
expanded = false expanded = false
if (name == import) { if (name == import) {
@ -404,6 +450,24 @@ private fun ContactsScreen(navController: NavController) {
vcfExportLauncher.launch("baresip_contacts.vcf") vcfExportLauncher.launch("baresip_contacts.vcf")
return@DropdownMenu return@DropdownMenu
} }
if (name == delete) {
title.value = confirmation
message.value = contactsDeleteQuestion
firstButtonText.value = cancel
onFirstClicked.value = { }
lastButtonText.value = delete
onLastClicked.value = {
for (contact in BaresipService.baresipContacts.value) {
val avatarFile = File(BaresipService.filesPath, "${contact.id}.png")
if (avatarFile.exists()) avatarFile.delete()
}
BaresipService.baresipContacts.value = mutableListOf()
Contact.saveBaresipContacts()
Contact.contactsUpdate()
}
showDialog.value = true
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,
@ -462,7 +526,23 @@ private fun ContactsScreen(navController: NavController) {
) )
}, },
content = { contentPadding -> content = { contentPadding ->
ContactsContent(ctx, navController, contentPadding, searchContactName) ContactsContent(
ctx,
navController,
contentPadding,
searchContactName,
showDialog,
title,
message,
firstButtonText,
onFirstClicked,
lastButtonText,
onLastClicked,
confirmation,
cancel,
delete,
contactDeleteQuestion
)
} }
) )
} }
@ -532,33 +612,19 @@ private fun ContactsContent(
ctx: Context, ctx: Context,
navController: NavController, navController: NavController,
contentPadding: PaddingValues, contentPadding: PaddingValues,
searchQuery: String searchQuery: String,
showDialog: androidx.compose.runtime.MutableState<Boolean>,
title: androidx.compose.runtime.MutableState<String>,
message: androidx.compose.runtime.MutableState<String>,
firstButtonText: androidx.compose.runtime.MutableState<String>,
onFirstClicked: androidx.compose.runtime.MutableState<() -> Unit>,
lastButtonText: androidx.compose.runtime.MutableState<String>,
onLastClicked: androidx.compose.runtime.MutableState<() -> Unit>,
confirmationText: String,
cancelText: String,
deleteText: String,
contactDeleteQuestion: String
) { ) {
val showConfirmationDialog = remember { mutableStateOf(false) }
val confirmationDialogMessage = remember { mutableStateOf("") }
val lastAction = remember { mutableStateOf({}) }
if (showConfirmationDialog.value)
AlertDialog(
showDialog = showConfirmationDialog,
title = stringResource(R.string.confirmation),
message = confirmationDialogMessage.value,
firstButtonText = stringResource(R.string.cancel),
lastButtonText = stringResource(R.string.delete),
onLastClicked = lastAction.value,
)
val alertTitle = remember { mutableStateOf("") }
val alertMessage = remember { mutableStateOf("") }
val showAlert = remember { mutableStateOf(false) }
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
lastButtonText = stringResource(R.string.ok),
)
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val scrollToContact = navController.currentBackStackEntry val scrollToContact = navController.currentBackStackEntry
@ -666,11 +732,15 @@ private fun ContactsContent(
navController.navigate("contact/${contact.name()}/old") navController.navigate("contact/${contact.name()}/old")
}, },
onLongClick = { onLongClick = {
confirmationDialogMessage.value = String.format( title.value = confirmationText
ctx.getString(R.string.contact_delete_question), message.value = String.format(
contactDeleteQuestion,
contact.name() contact.name()
) )
lastAction.value = { firstButtonText.value = cancelText
onFirstClicked.value = { }
lastButtonText.value = deleteText
onLastClicked.value = {
val id = contact.id val id = contact.id
val avatarFile = File( val avatarFile = File(
BaresipService.filesPath, BaresipService.filesPath,
@ -688,7 +758,7 @@ private fun ContactsContent(
} }
Contact.removeBaresipContact(contact) Contact.removeBaresipContact(contact)
} }
showConfirmationDialog.value = true showDialog.value = true
} }
) )
) )
@ -706,18 +776,22 @@ private fun ContactsContent(
navController.navigate("contact/${contact.name()}/old") navController.navigate("contact/${contact.name()}/old")
}, },
onLongClick = { onLongClick = {
confirmationDialogMessage.value = String.format( title.value = confirmationText
ctx.getString(R.string.contact_delete_question), message.value = String.format(
contactDeleteQuestion,
contact.name() contact.name()
) )
lastAction.value = { firstButtonText.value = cancelText
onFirstClicked.value = { }
lastButtonText.value = deleteText
onLastClicked.value = {
ctx.contentResolver.delete( ctx.contentResolver.delete(
ContactsContract.RawContacts.CONTENT_URI, ContactsContract.RawContacts.CONTENT_URI,
ContactsContract.Contacts.DISPLAY_NAME + "='" + contact.name() + "'", ContactsContract.Contacts.DISPLAY_NAME + "='" + contact.name() + "'",
null null
) )
} }
showConfirmationDialog.value = true showDialog.value = true
} }
) )
) )

View File

@ -475,6 +475,7 @@
<string name="send_message">Send Message</string> <string name="send_message">Send Message</string>
<string name="send_email">Send Email</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="contacts_delete_question">Do you want to delete all contacts?</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="import_contacts">Import</string> <string name="import_contacts">Import</string>
<string name="export_contacts">Export</string> <string name="export_contacts">Export</string>