Files
2026-07-21 09:42:55 +03:00

782 lines
37 KiB
Kotlin

package com.tutpro.baresip
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import androidx.core.graphics.scale
import android.provider.ContactsContract
import android.util.Base64
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
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.Menu
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import coil.compose.AsyncImage
import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.TextAvatar
import com.tutpro.baresip.CustomElements.verticalScrollbar
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
const val avatarSize: Int = 96
fun NavGraphBuilder.contactsScreenRoute(navController: NavController) {
composable("contacts") { _ ->
ContactsScreen(navController = navController)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContactsScreen(navController: NavController) {
val ctx = LocalContext.current
val activity = LocalActivity.current!!
var searchContactName by remember { mutableStateOf("") }
val consentRequest = stringResource(R.string.consent_request)
val contactsConsent = stringResource(R.string.contacts_consent)
val deny = stringResource(R.string.deny)
val accept = stringResource(R.string.accept)
val notice = stringResource(R.string.notice)
val noAndroidContacts = stringResource(R.string.no_android_contacts)
val ok = stringResource(R.string.ok)
var expanded by remember { mutableStateOf(false) }
val both = stringResource(R.string.both)
val import = stringResource(R.string.import_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(
contract = ActivityResultContracts.CreateDocument("text/vcard")
) { uri: android.net.Uri? ->
if (uri != null)
try {
ctx.contentResolver.openOutputStream(uri)?.use { outputStream ->
val writer = outputStream.bufferedWriter()
for (contact in BaresipService.baresipContacts.value) {
writer.write("BEGIN:VCARD\n")
writer.write("VERSION:3.0\n")
writer.write("FN:${contact.name}\n")
val nameParts = contact.name.split(" ", limit = 2)
if (nameParts.size == 2)
writer.write("N:${nameParts[1]};${nameParts[0]};;;\n")
else
writer.write("N:;${contact.name};;;\n")
if (contact.email.isNotEmpty())
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) {
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")
}
writer.flush()
}
Toast.makeText(ctx, R.string.contact_export_success, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.e(TAG, "Failed to export VCF: ${e.message}")
Toast.makeText(ctx, R.string.contact_export_failure, Toast.LENGTH_SHORT).show()
}
}
val vcfImportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: android.net.Uri? ->
if (uri != null)
try {
ctx.contentResolver.openInputStream(uri)?.use { inputStream ->
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 email = ""
val uris = mutableListOf<Contact.ContactUri>()
var photoBase64 = ""
var contactNo = 0
val newBaresipContacts = BaresipService.baresipContacts.value.toMutableList()
for (line in lines)
when {
line.startsWith("BEGIN:VCARD", ignoreCase = true) -> {
name = ""
email = ""
uris.clear()
photoBase64 = ""
}
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 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 (uris.none { it.uri == telUri }) uris.add(Contact.ContactUri(telUri, label))
}
}
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 (uris.none { it.uri == fullSipUri }) uris.add(Contact.ContactUri(fullSipUri, label))
}
}
line.startsWith("PHOTO", ignoreCase = true) && line.contains("BASE64", ignoreCase = true) -> {
photoBase64 = line.substringAfter(":").trim()
}
line.startsWith("END:VCARD", ignoreCase = true) -> {
if (name.isNotEmpty() && (uris.isNotEmpty() || email.isNotEmpty())) {
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 = Utils.decodeSampledBitmapFromByteArray(decodedString, 192, 192)
if (decodedByte != null) {
val scaledByte = decodedByte.scale(192, 192)
val avatarFile = File(BaresipService.filesPath, "$contactId.png")
try {
val out = FileOutputStream(avatarFile)
scaledByte.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
} catch (e: Exception) {
Log.e(TAG, "Failed to save photo for $name: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decode photo for $name: ${e.message}")
}
if (existingContact != null) {
for (u in uris)
if (existingContact.uris.none { it.uri == u.uri })
existingContact.uris.add(u)
if (existingContact.email.isEmpty() && email.isNotEmpty())
existingContact.email = email
}
else
newBaresipContacts.add(
Contact.BaresipContact(
name,
ArrayList(uris),
email,
Utils.randomColor(),
contactId,
false
)
)
}
}
}
BaresipService.baresipContacts.value = newBaresipContacts.toList()
Contact.saveBaresipContacts()
Contact.restoreBaresipContacts()
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 call = stringResource(R.string.call)
val showCall = stringResource(R.string.show_call)
val contactActionName = remember(BaresipService.contactAction) {
listOf(if (BaresipService.contactAction == "call") call else showCall)
}
val contactNames = remember(BaresipService.contactsMode) {
val names = mutableListOf("baresip", "Android", both)
val values = listOf("baresip", "android", "both")
val index = values.indexOf(BaresipService.contactsMode)
if (index != -1) {
val name = names.removeAt(index)
names.add(0, name)
}
names
}
val contactValues = remember(BaresipService.contactsMode) {
val values = mutableListOf("baresip", "android", "both")
val index = values.indexOf(BaresipService.contactsMode)
if (index != -1) {
val value = values.removeAt(index)
values.add(0, value)
}
values
}
val showDialog = remember { mutableStateOf(false) }
val showNoticeDialog = remember { mutableStateOf(false) }
val title = remember { mutableStateOf("") }
val message = remember { mutableStateOf("") }
val firstButtonText = remember { mutableStateOf("") }
val onFirstClicked = remember { mutableStateOf({}) }
val lastButtonText = remember { mutableStateOf("") }
val onLastClicked = remember { mutableStateOf({}) }
var pendingMode by remember { mutableStateOf("") }
AlertDialog(
showDialog = showDialog,
title = title.value,
message = message.value,
firstButtonText = firstButtonText.value,
onFirstClicked = onFirstClicked.value,
lastButtonText = lastButtonText.value,
onLastClicked = onLastClicked.value,
)
fun setContactsMode(mode: String) {
if (Config.variable("contacts_mode").lowercase() != mode) {
Config.replaceVariable("contacts_mode", mode)
BaresipService.contactsMode = mode
val baresipService = Intent(ctx, BaresipService::class.java)
when (mode) {
"baresip" -> {
BaresipService.androidContacts.value = listOf()
Contact.restoreBaresipContacts()
baresipService.action = "Stop Content Observer"
}
"android" -> {
BaresipService.baresipContacts.value = mutableListOf()
Contact.loadAndroidContacts(ctx)
baresipService.action = "Start Content Observer"
}
"both" -> {
Contact.restoreBaresipContacts()
Contact.loadAndroidContacts(ctx)
baresipService.action = "Start Content Observer"
}
}
Contact.contactsUpdate()
Config.save()
ContextCompat.startForegroundService(ctx, baresipService)
}
}
val requestPermissionsLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions[Manifest.permission.READ_CONTACTS] == true &&
permissions[Manifest.permission.WRITE_CONTACTS] == true)
if (pendingMode.isNotEmpty())
setContactsMode(pendingMode)
pendingMode = ""
}
AlertDialog(
showDialog = showNoticeDialog,
title = notice,
message = noAndroidContacts,
lastButtonText = ok,
onLastClicked = {
requestPermissionsLauncher.launch(
arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)
)
}
)
Scaffold(
modifier = Modifier.fillMaxSize().imePadding(),
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
Spacer(Modifier.statusBarsPadding())
TopAppBar(
title = {
Text(text = stringResource(R.string.contacts), fontWeight = FontWeight.Bold)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
),
navigationIcon = {
IconButton(
onClick = { navController.navigateUp() }
) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
IconButton(onClick = { expanded = !expanded }) {
Icon(imageVector = Icons.Filled.Menu, contentDescription = "Menu")
}
CustomElements.DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
items = contactNames + contactActionName + import + export + delete,
onItemClick = { name ->
expanded = false
if (name == call || name == showCall) {
val newAction = if (BaresipService.contactAction == "call") "dial" else "call"
BaresipService.contactAction = newAction
Config.replaceVariable("contact_action", newAction)
Config.save()
return@DropdownMenu
}
if (name == import) {
vcfImportLauncher.launch(arrayOf("text/vcard", "text/x-vcard"))
return@DropdownMenu
}
if (name == export) {
val fileName = "contacts_" +
SimpleDateFormat(
"yyyy_MM_dd_HH_mm",
Locale.getDefault()
).format(Date()) + ".vcf"
vcfExportLauncher.launch(fileName)
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 contactsPermissions = arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
)
if (mode != "baresip" && !Utils.checkPermissions(ctx, contactsPermissions)) {
title.value = consentRequest
message.value = contactsConsent
firstButtonText.value = deny
onFirstClicked.value = { }
lastButtonText.value = accept
onLastClicked.value = {
showDialog.value = false
if (ContextCompat.checkSelfPermission(
ctx,
Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
ctx,
Manifest.permission.WRITE_CONTACTS
) == PackageManager.PERMISSION_GRANTED
) {
Log.d(TAG, "Contacts permissions already granted")
setContactsMode(mode)
}
else {
if (shouldShowRequestPermissionRationale(
activity, Manifest.permission.READ_CONTACTS
) ||
shouldShowRequestPermissionRationale(
activity, Manifest.permission.WRITE_CONTACTS
)
) {
pendingMode = mode
showNoticeDialog.value = true
}
else {
pendingMode = mode
requestPermissionsLauncher.launch(contactsPermissions)
}
}
}
showDialog.value = true
}
else
setContactsMode(mode)
}
)
}
)
}
},
bottomBar = {
BottomBar(
navController = navController,
searchContactName = searchContactName,
onSearchContactNameChange = { searchContactName = it }
)
},
content = { contentPadding ->
ContactsContent(
ctx,
navController,
contentPadding,
searchContactName,
showDialog,
title,
message,
firstButtonText,
onFirstClicked,
lastButtonText,
onLastClicked,
confirmation,
cancel,
delete,
contactDeleteQuestion
)
}
)
}
@Composable
private fun BottomBar(
navController: NavController,
searchContactName: String,
onSearchContactNameChange: (String) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = searchContactName,
onValueChange = {
onSearchContactNameChange(it)
if (it.isBlank())
keyboardController?.hide()
},
modifier = Modifier.weight(1f),
singleLine = true,
trailingIcon = {
if (searchContactName.isNotEmpty())
Icon(
Icons.Outlined.Clear,
contentDescription = null,
modifier = Modifier.clickable {
onSearchContactNameChange("")
keyboardController?.hide()
},
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
label = { Text(stringResource(R.string.search)) },
textStyle = TextStyle(fontSize = 18.sp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done)
)
SmallFloatingActionButton(
onClick = { navController.navigate("contact//new") },
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
) {
Icon(
imageVector = Icons.Filled.Add,
modifier = Modifier.size(36.dp),
contentDescription = stringResource(R.string.add)
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ContactsContent(
ctx: Context,
navController: NavController,
contentPadding: PaddingValues,
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 lazyListState = rememberLazyListState()
val scrollToContact = navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<String>("scrollToContact")
?.observeAsState()
var lastSearchQuery by remember { mutableStateOf("") }
LaunchedEffect(searchQuery) {
if (searchQuery.isBlank() && lastSearchQuery.isNotBlank())
lazyListState.scrollToItem(0)
lastSearchQuery = searchQuery
}
val filteredContacts = remember(BaresipService.contacts, searchQuery) {
if (searchQuery.isBlank())
BaresipService.contacts.map { contact ->
Triple(contact, buildAnnotatedString { append(contact.name()) }, null)
}
else {
val normalizedQuery = Utils.unaccent(searchQuery)
val numericQuery = searchQuery.filter { it.isDigit() || it == '+' }
BaresipService.contacts.mapNotNull { contact ->
val nameMatch = Utils.unaccent(contact.name()).contains(normalizedQuery, ignoreCase = true)
var matchingUri: Contact.ContactUri? = null
if (numericQuery.isNotEmpty()) {
matchingUri = contact.uris().find {
it.uri.startsWith("tel:") && it.uri.substring(4).contains(numericQuery)
}
}
if (nameMatch || matchingUri != null) {
val annotatedName = if (nameMatch)
Utils.buildAnnotatedStringWithHighlight(contact.name(), searchQuery)
else
AnnotatedString(contact.name())
Triple(contact, annotatedName, matchingUri)
} else {
null
}
}
}
}
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(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(contentPadding)
.padding(start = 16.dp, end = 4.dp, top = 16.dp, bottom = 10.dp)
.verticalScrollbar(state = lazyListState),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
itemsIndexed(
filteredContacts, key = { index, (contact, _, _) -> "${contact.id()}_$index" }
) { _, (contact, annotatedName, matchingUri) ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
when (contact) {
is Contact.BaresipContact -> {
val avatarImage = contact.avatarImage
if (avatarImage != null)
Image(
bitmap = avatarImage.asImageBitmap(),
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier.size(36.dp).clip(CircleShape)
)
else
TextAvatar(contact.name(), contact.color)
}
is Contact.AndroidContact -> {
val thumbNailUri = contact.thumbnailUri
if (thumbNailUri != null)
AsyncImage(
model = thumbNailUri,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier.size(36.dp).clip(CircleShape),
)
else
TextAvatar(contact.name(), contact.color)
}
}
val textModifier = Modifier
.weight(1f)
.padding(start = 10.dp)
.combinedClickable(
onClick = { navController.navigate("contact/${contact.name()}/old") },
onLongClick = {
title.value = confirmationText
message.value = String.format(contactDeleteQuestion, contact.name())
firstButtonText.value = cancelText
onFirstClicked.value = { }
lastButtonText.value = deleteText
onLastClicked.value = {
when (contact) {
is Contact.BaresipContact -> {
val id = contact.id
val avatarFile = File(BaresipService.filesPath, "$id.png")
if (avatarFile.exists())
try {
avatarFile.delete()
} catch (e: IOException) {
Log.e(TAG, "Could not delete file $id.png: ${e.message}")
}
Contact.removeBaresipContact(contact)
}
is Contact.AndroidContact -> {
ctx.contentResolver.delete(
ContactsContract.RawContacts.CONTENT_URI,
ContactsContract.Contacts.DISPLAY_NAME + "='" + contact.name() + "'",
null
)
}
}
}
showDialog.value = true
}
)
Column(
modifier = if (contact is Contact.AndroidContact)
textModifier.padding(top = 4.dp, bottom = 4.dp)
else
textModifier
) {
Text(
text = annotatedName,
fontSize = 20.sp,
fontStyle = if (contact.favorite()) FontStyle.Italic else FontStyle.Normal
)
if (matchingUri != null) {
val tel = matchingUri.uri.substring(4)
val annotatedTel = Utils.buildAnnotatedStringWithHighlight(
tel,
searchQuery.filter { it.isDigit() || it == '+' })
Text(
text = buildAnnotatedString {
if (matchingUri.label.isNotEmpty())
append("${matchingUri.label} ")
append(annotatedTel)
},
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}