Merged BaresipContactScreen to ContactScreen

This commit is contained in:
Juha Heinanen
2026-05-19 09:38:13 +03:00
parent caa1baf0c8
commit d9a9fd63fb
8 changed files with 972 additions and 1154 deletions

View File

@ -1,951 +0,0 @@
package com.tutpro.baresip
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds
import android.provider.ContactsContract.Contacts.Data
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.Remove
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.compose.rememberAsyncImagePainter
import com.tutpro.baresip.CustomElements.AlertDialog
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
fun NavGraphBuilder.baresipContactScreenRoute(navController: NavController) {
composable(
route = "baresip_contact/{uri_or_name}/{kind}",
arguments = listOf(
navArgument("uri_or_name") { type = NavType.StringType },
navArgument("kind") { type = NavType.StringType }
)
) { backStackEntry ->
val uriOrNameArg = backStackEntry.arguments?.getString("uri_or_name")!!
val kindArg = backStackEntry.arguments?.getString("kind")!!
ContactScreen(
navController = navController,
uriOrNameArg = uriOrNameArg,
kindArg = kindArg
)
}
}
private data class ScreenState(
val new: Boolean = false,
val favorite: Boolean = false,
val android: Boolean = false,
val id: Long = 0,
val newId: Long = 0,
val name: String = "",
val uris: List<String> = emptyList(),
val email: String = "",
val color: Int = 0,
val avatarImageUri: String? = null,
val tmpAvatarFile: File? = null,
val isLoading: Boolean = true
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContactScreen(
navController: NavController,
uriOrNameArg: String,
kindArg: String
) {
val ctx = LocalContext.current
var screenState by remember { mutableStateOf(ScreenState()) }
val title = if (screenState.new)
stringResource(R.string.new_contact)
else
uriOrNameArg
LaunchedEffect(key1 = uriOrNameArg, key2 = kindArg) {
val isNew = kindArg == "new"
if (isNew) {
val time = System.currentTimeMillis()
screenState = ScreenState(
new = true,
name = "",
uris = if (uriOrNameArg == "") emptyList() else listOf(uriOrNameArg),
email = "",
favorite = false,
android = BaresipService.contactsMode == "android",
color = Utils.randomColor(),
id = time,
newId = time,
isLoading = false
)
}
else {
val contact = Contact.baresipContact(uriOrNameArg)!!
val avatarFile = File(BaresipService.filesPath, "${contact.id}.png")
screenState = ScreenState(
new = false,
name = uriOrNameArg,
uris = contact.uris,
email = contact.email,
favorite = contact.favorite,
android = false,
color = contact.color,
id = contact.id,
newId = contact.id,
avatarImageUri = if (contact.avatarImage != null && avatarFile.exists())
Uri.fromFile(avatarFile).toString()
else
null,
isLoading = false
)
}
}
val onBack: () -> Unit = {
screenState.tmpAvatarFile?.let { tempFile ->
if (tempFile.exists()) {
Log.d(TAG, "Back pressed, deleting temp avatar: ${tempFile.name}")
Utils.deleteFile(tempFile)
}
}
navController.navigateUp()
}
val onCheck: () -> Unit = {
val result = checkOnClick(
ctx = ctx,
currentState = screenState,
uriOrNameArg = uriOrNameArg,
)
if (result) {
navController.previousBackStackEntry?.savedStateHandle?.set("scrollToContact", screenState.name)
navController.navigateUp()
}
}
BackHandler(enabled = true) {
onBack()
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.imePadding(),
containerColor = MaterialTheme.colorScheme.background,
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
) {
TopAppBar(title, onBack = onBack, onCheck = onCheck)
}
},
content = { contentPadding ->
if (!screenState.isLoading) {
ContactContent(
contentPadding = contentPadding,
screenState = screenState,
onStateChange = { newState -> screenState = newState }
)
} else {
// Optional: Show a loading indicator
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(title: String, onBack: () -> Unit, onCheck: () -> Unit) {
TopAppBar(
title = { Text(text = title, fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
),
windowInsets = WindowInsets(0, 0, 0, 0),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
actions = {
IconButton(onClick = onCheck) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Check"
)
}
}
)
}
private val alertTitle = mutableStateOf("")
private val alertMessage = mutableStateOf("")
private val showAlert = mutableStateOf(false)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ContactContent(
contentPadding: PaddingValues,
screenState: ScreenState,
onStateChange: (ScreenState) -> Unit
) {
val ctx = LocalContext.current
if (showAlert.value) {
AlertDialog(
showDialog = showAlert,
title = alertTitle.value,
message = alertMessage.value,
lastButtonText = stringResource(R.string.ok),
)
}
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)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Avatar(
ctx = ctx,
name = screenState.name,
color = screenState.color,
currentAvatarUri = screenState.avatarImageUri,
onNewAvatarChosen = { processedTmpFile, generatedNewId ->
onStateChange(
screenState.copy(
avatarImageUri = Uri.fromFile(processedTmpFile).toString(),
tmpAvatarFile = processedTmpFile,
newId = generatedNewId
)
)
},
onAvatarColorChange = { newRandomColor ->
// User long-clicked to change color, this means discarding any image.
// The old tempAvatarFile (if any) should be deleted.
screenState.tmpAvatarFile?.let {
if (it.exists())
Utils.deleteFile(it)
}
onStateChange(
screenState.copy(
color = newRandomColor,
avatarImageUri = null,
tmpAvatarFile = null
)
)
}
)
ContactName(
name = screenState.name,
new = screenState.new,
onNameChange = { newName -> onStateChange(screenState.copy(name = newName)) }
)
ContactUris(
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,
onFavoriteChange = {
newFavorite -> onStateChange(screenState.copy(favorite = newFavorite))
}
)
if (screenState.new && BaresipService.contactsMode == "both")
Android(
ctx = ctx,
android = screenState.android,
onAndroidChange = { newAndroid -> onStateChange(screenState.copy(android = newAndroid)) }
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Avatar(
ctx: Context,
name: String,
color: Int,
currentAvatarUri: String?,
onNewAvatarChosen: (newImageFile: File, newImageId: Long) -> Unit,
onAvatarColorChange: (newColor: Int) -> Unit
) {
val avatarImagePicker =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
try {
val inputStream = ctx.contentResolver.openInputStream(uri)
val avatarBitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (avatarBitmap == null) {
Log.e(TAG, "Failed to decode bitmap from URI: $uri")
return@rememberLauncherForActivityResult
}
val scaledBitmap = avatarBitmap.scale(192, 192) // Define desired scale
val orientationInputStream = ctx.contentResolver.openInputStream(uri)
val exif = if (orientationInputStream != null) ExifInterface(orientationInputStream) else null
orientationInputStream?.close()
val orientation = exif?.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
) ?: ExifInterface.ORIENTATION_NORMAL
val rotatedBitmap = rotateBitmap(scaledBitmap, orientation)
val newImageId = System.currentTimeMillis()
val tempNewImageFile = File(BaresipService.filesPath, "${newImageId}.png")
if (saveBitmap(rotatedBitmap, tempNewImageFile)) {
onNewAvatarChosen(tempNewImageFile, newImageId)
} else {
Log.e(TAG, "Failed to save processed avatar image to ${tempNewImageFile.absolutePath}")
if (tempNewImageFile.exists()) Utils.deleteFile(tempNewImageFile)
}
} catch (e: Exception) {
Log.e(TAG, "Could not process avatar image: ${e.message}")
}
}
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(avatarSize.dp)
.clip(CircleShape)
.background(if (currentAvatarUri == null) Color(color) else Color.Transparent)
.combinedClickable(
onClick = {
avatarImagePicker.launch("image/*")
},
onLongClick = {
onAvatarColorChange(Utils.randomColor())
}
)
) {
if (currentAvatarUri == null) {
Box(
modifier = Modifier.size(avatarSize.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(SolidColor(Color(color)))
}
val text = if (name.isNotBlank()) name.take(1).uppercase() else "?"
Text(text, fontSize = 72.sp, color = Color.White)
}
} else {
Image(
painter = rememberAsyncImagePainter(model = currentAvatarUri),
contentDescription = stringResource(R.string.avatar_image),
contentScale = ContentScale.Crop,
modifier = Modifier.size(avatarSize.dp).clip(CircleShape)
)
}
}
}
}
@Composable
private fun ContactName(name: String, new: Boolean, onNameChange: (String) -> Unit) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = name,
placeholder = { Text(stringResource(R.string.contact_name)) },
onValueChange = onNameChange,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 18.sp),
label = { Text(stringResource(R.string.contact_name)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words
)
)
LaunchedEffect(new) {
if (new)
focusRequester.requestFocus()
}
}
@Composable
private fun ContactUris(uris: List<String>, onUrisChange: (List<String>) -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
uris.forEachIndexed { index, uri ->
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)
)
if (uris.size > 1) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
IconButton(
onClick = {
val newList = uris.toMutableList()
newList.removeAt(index)
onUrisChange(newList.toList())
},
modifier = Modifier.padding(start = 2.dp)
) {
Icon(
imageVector = Icons.Filled.Remove,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp)
)
}
}
}
}
}
IconButton(
onClick = {
val newList = uris.toMutableList()
newList.add("")
onUrisChange(newList.toList())
},
modifier = Modifier.align(Alignment.Start)
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@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(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = stringResource(R.string.favorite),
modifier = Modifier.weight(1f)
.clickable {
alertTitle.value = ctx.getString(R.string.favorite)
alertMessage.value = ctx.getString(R.string.favorite_help)
showAlert.value = true
},
)
Switch(
checked = favorite,
onCheckedChange = onFavoriteChange
)
}
}
@Composable
private fun Android(ctx: Context, android: Boolean, onAndroidChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Text(
text = stringResource(R.string.android),
modifier = Modifier.weight(1f)
.clickable {
alertTitle.value = ctx.getString(R.string.android)
alertMessage.value = ctx.getString(R.string.android_contact_help)
showAlert.value = true
},
)
Switch(
checked = android,
onCheckedChange = onAndroidChange
)
}
}
private fun checkOnClick(
ctx: Context,
currentState: ScreenState,
uriOrNameArg: String
): Boolean {
val newUris = ArrayList<String>()
for (uri in currentState.uris) {
var u = 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"
if (!Utils.checkUri(u)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_sip_or_tel_uri), u)
showAlert.value = true
return false
}
newUris.add(u)
}
if (newUris.isEmpty() && currentState.email.trim().isEmpty()) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.sip_or_tel_uri)
showAlert.value = true
return false
}
var newName = currentState.name.trim()
if (newName == "") newName = newUris[0].substringAfter(":")
if (!Utils.checkName(newName)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.invalid_contact), newName)
showAlert.value = true
return false
}
val alert: Boolean = if (currentState.new)
Contact.nameExists(newName, BaresipService.contacts, true)
else {
(uriOrNameArg != newName) && Contact.nameExists(newName, BaresipService.contacts, false)
}
if (alert) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = String.format(ctx.getString(R.string.contact_already_exists), newName)
showAlert.value = true
return false
}
var idToUse = currentState.id
if (currentState.tmpAvatarFile != null && currentState.tmpAvatarFile.exists()) {
if (currentState.id != currentState.newId) { // Avatar changed, implying new ID
val oldAvatar = File(BaresipService.filesPath, "${currentState.id}.png")
if (oldAvatar.exists()) Utils.deleteFile(oldAvatar)
idToUse = currentState.newId
}
} else if (currentState.avatarImageUri == null) { // Avatar was explicitly cleared
val avatarFile = File(BaresipService.filesPath, "$idToUse.png")
if (avatarFile.exists())
Utils.deleteFile(avatarFile)
}
val contact: Contact.BaresipContact =
Contact.BaresipContact(
newName,
newUris,
currentState.email.trim(),
currentState.color,
idToUse,
currentState.favorite
)
if (currentState.avatarImageUri == null)
contact.avatarImage = null
else {
val imageFilePath = BaresipService.filesPath + "/${idToUse}.png"
contact.avatarImage = BitmapFactory.decodeFile(imageFilePath)
}
if (currentState.android) {
addOrUpdateAndroidContact(ctx, contact)
if (contact.favorite) {
val contentValues = ContentValues()
contentValues.put(ContactsContract.Contacts.STARRED, 1)
try {
ctx.contentResolver.update(
ContactsContract.RawContacts.CONTENT_URI, contentValues,
ContactsContract.Contacts.DISPLAY_NAME + "='" + newName + "'", null
)
} catch (e: Exception) {
Log.e(TAG, "Update of Android favorite failed: ${e.message}")
}
}
}
else {
if (currentState.new)
Contact.addBaresipContact(contact)
else
Contact.updateBaresipContact(currentState.id, contact)
}
return true
}
private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_NORMAL -> return bitmap
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.setRotate(180f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(-90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
else -> return bitmap
}
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0,
bitmap.width, bitmap.height, matrix, true)
bitmap.recycle()
return rotatedBitmap
}
private fun saveBitmap(bitmap: Bitmap, file: File): Boolean {
if (file.exists()) file.delete()
try {
val out = FileOutputStream(file)
val scaledBitmap = bitmap.scale(avatarSize, avatarSize)
scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
Log.d(TAG, "Saved bitmap to ${file.absolutePath} of length ${file.length()}")
} catch (e: Exception) {
Log.e(TAG, "Failed to save bitmap to ${file.absolutePath}: $e")
return false
}
return true
}
private fun addOrUpdateAndroidContact(ctx: Context, contact: Contact.BaresipContact) {
val projection = arrayOf(ContactsContract.Data.RAW_CONTACT_ID)
val selection = ContactsContract.Data.MIMETYPE + "='" +
CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE + "' AND " +
CommonDataKinds.StructuredName.DISPLAY_NAME + "='" + contact.name + "'"
val c: Cursor? = ctx.contentResolver.query(
ContactsContract.Data.CONTENT_URI, projection,
selection, null, null)
if (c != null && c.moveToFirst()) {
updateAndroidContact(ctx, c.getLong(0), contact)
} else {
addAndroidContact(ctx, contact)
}
c?.close()
}
private fun addAndroidContact(ctx: Context, contact: Contact.BaresipContact): Boolean {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null).build())
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.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
else
CommonDataKinds.Phone.CONTENT_ITEM_TYPE
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, mimeType)
.withValue(Data.DATA1, uri.substringAfter(":"))
.build())
}
if (contact.avatarImage != null) {
val photoData: ByteArray? = bitmapToPNGByteArray(contact.avatarImage!!)
if (photoData != null) {
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, 0)
.withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Photo.PHOTO, photoData)
.build())
}
}
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of contact ${contact.name} failed: ${e.message}")
return false
}
return true
}
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)
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) {
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)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, mimeType)
.withValue(Data.DATA1, uri.substringAfter(":"))
.build())
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of SIP URI $uri failed: ${e.message}")
}
}
private fun updateAndroidUri(ctx: Context, rawContactId: Long, uri: String): Int {
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)
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)
} catch (e: Exception) {
Log.e(TAG, "Update of Android URI $uri failed: ${e.message}")
0
}
}
private fun addAndroidPhoto(ctx: Context, rawContactId: Long, photoBits: Bitmap) {
val photoBytes = bitmapToPNGByteArray(photoBits)
if (photoBytes != null) {
val ops = ArrayList<ContentProviderOperation>()
ops.add(
ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, rawContactId)
.withValue(Data.MIMETYPE, CommonDataKinds.Photo.CONTENT_ITEM_TYPE)
.withValue(CommonDataKinds.Photo.PHOTO, photoBytes)
.build())
try {
ctx.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
} catch (e: Exception) {
Log.e(TAG, "Adding of Android photo failed: ${e.message}")
}
}
}
private fun addAndroidEmail(ctx: Context, rawContactId: Long, email: String) {
val ops = ArrayList<ContentProviderOperation>()
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
else
bitmapToPNGByteArray(photoBits)
val contentValues = ContentValues()
contentValues.put(CommonDataKinds.Photo.PHOTO, photoBytes)
val where = "${ContactsContract.Data.RAW_CONTACT_ID}=$rawContactId and " +
"${ContactsContract.Data.MIMETYPE}='${CommonDataKinds.Photo.CONTENT_ITEM_TYPE}'"
return try {
ctx.contentResolver.update(ContactsContract.Data.CONTENT_URI, contentValues, where, null)
} catch (e: Exception) {
Log.e(TAG, "updateAndroidPhoto failed: ${e.message}")
0
}
}
private fun bitmapToPNGByteArray(bitmap: Bitmap): ByteArray? {
val size = bitmap.width * bitmap.height * 4
val out = ByteArrayOutputStream(size)
return try {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
out.close()
out.toByteArray()
} catch (e: Exception) {
Log.w(TAG, "Unable to serialize photo: ${e.message}")
null
}
}

View File

@ -283,7 +283,7 @@ private fun Blocked(ctx: Context, navController: NavController, blocked: Mutable
peerUri)
lastButtonText.value = ctx.getString(R.string.add_contact)
lastAction.value = {
navController.navigate("baresip_contact/$peerUri/new")
navController.navigate("contact/$peerUri/new")
}
showDialog.value = true
})

View File

@ -436,7 +436,7 @@ private fun Calls(
secondButtonText.value = ctx.getString(R.string.add_contact)
secondAction.value = {
val uri = Utils.sipToTel(peerUri)
navController.navigate("baresip_contact/$uri/new")
navController.navigate("contact/$uri/new")
}
lastButtonText.value = ctx.getString(R.string.delete)
lastAction.value = {

View File

@ -418,7 +418,7 @@ private fun Messages(
)
secondButtonText.value = ctx.getString(R.string.add_contact)
secondAction.value = {
navController.navigate("baresip_contact/$peerUri/new")
navController.navigate("contact/$peerUri/new")
}
lastButtonText.value = ctx.getString(R.string.delete)
lastAction.value = {

View File

@ -369,7 +369,7 @@ private fun Chats(
}
lastButtonText.value = ctx.getString(R.string.add_contact)
lastAction.value = {
navController.navigate("baresip_contact/${message.peerUri}/new")
navController.navigate("contact/${message.peerUri}/new")
}
}
showDialog.value = true

File diff suppressed because it is too large Load Diff

View File

@ -473,7 +473,7 @@ private fun BottomBar(
)
)
SmallFloatingActionButton(
onClick = { navController.navigate("baresip_contact//new") },
onClick = { navController.navigate("contact//new") },
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
) {
@ -621,7 +621,7 @@ private fun ContactsContent(
.padding(start = 10.dp)
.combinedClickable(
onClick = {
navController.navigate("contact/${contact.name()}")
navController.navigate("contact/${contact.name()}/old")
},
onLongClick = {
confirmationDialogMessage.value = String.format(
@ -650,18 +650,6 @@ private fun ContactsContent(
}
)
)
SmallFloatingActionButton(
modifier = Modifier.padding(end = 10.dp),
onClick = { navController.navigate("baresip_contact/${contact.name()}/old") },
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
) {
Icon(
imageVector = Icons.Filled.Edit,
modifier = Modifier.size(28.dp),
contentDescription = stringResource(R.string.edit)
)
}
}
is Contact.AndroidContact -> {
@ -673,7 +661,7 @@ private fun ContactsContent(
.padding(start = 10.dp, top = 4.dp, bottom = 4.dp)
.combinedClickable(
onClick = {
navController.navigate("contact/${contact.name()}")
navController.navigate("contact/${contact.name()}/old")
},
onLongClick = {
confirmationDialogMessage.value = String.format(

View File

@ -286,7 +286,6 @@ class MainActivity : ComponentActivity() {
accountScreenRoute(navController)
codecsScreenRoute(navController)
contactsScreenRoute(navController)
baresipContactScreenRoute(navController)
contactScreenRoute(navController, viewModel)
callsScreenRoute(navController, viewModel)
callDetailsScreenRoute(navController, viewModel)